Merge pull request #153 from goteo/develop

[release] Prod setup
This commit is contained in:
2025-09-11 20:19:20 +02:00
committed by GitHub
180 changed files with 6262 additions and 2008 deletions
+5 -7
View File
@@ -1,14 +1,12 @@
/public/bundles/
/var/
var/
vendor/
.phpunit.result.cache
/phpunit.xml
phpunit.xml
/phpunit.xml
.phpunit.result.cache
.php-cs-fixer.php
.php-cs-fixer.cache
/.php-cs-fixer.php
/.php-cs-fixer.cache
.git/
.github/
.vscode/
+4 -1
View File
@@ -35,7 +35,6 @@ 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 ###
@@ -52,6 +51,10 @@ PAYPAL_CLIENT_SECRET=""
PAYPAL_WEBHOOK_ID="WEBHOOK_ID"
###< App\Gateway\Paypal\PaypalGateway ###
###> App\Money\MoneyService ###
CURRENCY_DEFAULT="EUR"
###< App\Money\MoneyService ###
###> symfony/lock ###
# Choose one of the stores below
# postgresql+advisory://db_user:db_password@localhost/db_name
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
with:
push: true
context: .
file: docker/php/Dockerfile
file: docker/dev/php/Dockerfile
target: prod
tags: ${{ secrets.REGISTRY_URL }}/goteo-v4:latest
+4
View File
@@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"dockerfilePath": "./docker/main/Dockerfile"
}
+5 -5
View File
@@ -17,7 +17,7 @@
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"doctrineencryptbundle/doctrine-encrypt-bundle": "^5.4",
"goteo/benzina": "^0.4",
"goteo/benzina": "^0.5",
"jolicode/automapper": "^9.2",
"mpratt/embera": "~2.0",
"nelmio/cors-bundle": "^2.3",
@@ -36,6 +36,7 @@
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/maker-bundle": "^1.62",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
@@ -44,7 +45,8 @@
"symfony/serializer": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/yaml": "6.4.*"
"symfony/yaml": "6.4.*",
"zenstruck/foundry": "^2.4"
},
"config": {
"allow-plugins": {
@@ -99,8 +101,6 @@
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/maker-bundle": "^1.62",
"symfony/phpunit-bridge": "^7.2",
"zenstruck/foundry": "^2.4"
"symfony/phpunit-bridge": "^7.2"
}
}
Generated
+929 -836
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,7 +32,7 @@ api_platform:
standard_put: true
pagination_enabled: true
pagination_items_per_page: 30
pagination_client_enabled: true
pagination_maximum_items_per_page: 100
pagination_client_items_per_page: true
use_symfony_listeners: true
swagger:
+1
View File
@@ -4,6 +4,7 @@ nelmio_cors:
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
allow_credentials: true
expose_headers: ['Link']
max_age: 3600
paths:
+2
View File
@@ -25,6 +25,8 @@ security:
access_token:
token_handler: App\Security\AccessTokenHandler
custom_authenticators:
- App\Security\CookieAuthenticator
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
+2
View File
@@ -1,5 +1,7 @@
when@dev: &dev
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
zenstruck_foundry:
persistence:
flush_once: true
when@test: *dev
+30 -4
View File
@@ -14,8 +14,14 @@ services:
_instanceof:
App\Gateway\GatewayInterface:
tags: ['app.gateway.gateway']
App\Library\Economy\Currency\ExchangeInterface:
tags: ['app.lib.economy.currency.exchange']
App\Money\Conversion\ExchangeInterface:
tags: ['app.money.conversion.exchange']
App\Money\Totalization\TotalizerInterface:
tags: ['app.money.totalization.totalizer']
App\Matchfunding\Formula\FormulaInterface:
tags: ['app.matchfunding.formula']
App\Matchfunding\Rule\RuleInterface:
tags: ['app.matchfunding.rule']
App\Service\Embed\Filter\EmbedFilterInterface:
tags: ['app.embed.embed_filter']
App\Service\Embed\Provider\EmbedProviderFactoryInterface:
@@ -63,6 +69,14 @@ services:
tags:
- { name: api_platform.uri_variables.transformer }
App\Matchfunding\Formula\FormulaLocator:
arguments:
- !tagged 'app.matchfunding.formula'
App\Matchfunding\Rule\RuleLocator:
arguments:
- !tagged 'app.matchfunding.rule'
App\Service\Auth\AuthService:
arguments:
- '%env(APP_SECRET)%'
@@ -82,9 +96,21 @@ services:
$hosts:
- 'peertube.plataformess.org'
App\Library\Economy\Currency\ExchangeLocator:
App\State\MoneyTotalStateProvider:
arguments:
- !tagged 'app.lib.economy.currency.exchange'
- !tagged 'api_platform.doctrine.orm.query_extension.collection'
App\Money\MoneyService:
arguments:
- '%env(CURRENCY_DEFAULT)%'
App\Money\Conversion\ExchangeLocator:
arguments:
- !tagged 'app.money.conversion.exchange'
App\Money\Totalization\TotalizerLocator:
arguments:
- !tagged 'app.money.totalization.totalizer'
App\Mapping\AutoMapper:
arguments:
+3 -3
View File
@@ -1,9 +1,9 @@
services:
php:
volumes:
# ./docker/php/ssl/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
- ./docker/php/conf.d/opcache.dev.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
- ./docker/php/conf.d/xdebug.dev.ini:/usr/local/etc/php/conf.d/xdebug.ini:ro
# ./docker/dev/php/ssl/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
- ./docker/dev/php/conf.d/opcache.dev.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
- ./docker/dev/php/conf.d/xdebug.dev.ini:/usr/local/etc/php/conf.d/xdebug.ini:ro
extra_hosts:
- "host.docker.internal:host-gateway"
user: "${UID:-1000}:${GID:-1000}"
+4 -4
View File
@@ -2,20 +2,20 @@ services:
php:
build:
context: .
dockerfile: docker/php/Dockerfile
dockerfile: docker/dev/php/Dockerfile
target: dev
container_name: goteo-api-php
volumes:
- .:/app
- ./docker/php/conf.d/openssl.ini:/usr/local/etc/php/conf.d/openssl.ini:ro
- ./docker/php/conf.d/opcache.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
- ./docker/dev/php/conf.d/openssl.ini:/usr/local/etc/php/conf.d/openssl.ini:ro
- ./docker/dev/php/conf.d/opcache.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
nginx:
image: nginx:alpine
container_name: goteo-api-nginx
volumes:
- .:/app
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
- ./docker/dev/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- "${APP_HTTP_PORT:-8090}:80"
- "${APP_HTTPS_PORT:-8091}:443"
@@ -47,7 +47,7 @@ FROM base AS prod
COPY . /app
RUN chown -R www-data:www-data /app
COPY /docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY /docker/dev/php/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER www-data
+70
View File
@@ -0,0 +1,70 @@
FROM php:8.3-fpm-alpine AS system-setup
RUN apk --no-cache add \
curl \
git \
linux-headers \
icu-dev \
zip \
libzip-dev \
unzip \
nginx \
supervisor \
$PHPIZE_DEPS && \
pecl install apcu
RUN curl --remote-name https://curl.se/ca/cacert.pem \
&& mv cacert.pem /etc/ssl/certs/ca-certificates.crt
RUN docker-php-ext-install \
pdo \
pdo_mysql \
intl \
zip \
opcache && \
docker-php-ext-enable apcu
COPY --from=composer:2.8.9 /usr/bin/composer /usr/local/bin/composer
COPY ./docker/main/default.conf /etc/nginx/http.d/default.conf
COPY ./docker/main/supervisord.conf /etc/supervisord.conf
COPY ./docker/main/www.conf /usr/local/etc/php-fpm.d/www.conf
COPY ./docker/main/local.ini /usr/local/etc/php/conf.d/local.ini
# FPM configuration via ENV
ENV FPM_PM=dynamic
ENV FPM_PM_MAX_CHILDREN=5
ENV FPM_PM_START_SERVERS=2
ENV FPM_PM_MIN_SPARE_SERVERS=1
ENV FPM_PM_MAX_SPARE_SERVERS=3
# PHP configuration via ENV
ENV LOG_TO_STDOUT=1
ENV PHP_MEMORY_LIMIT=512M
ENV PHP_MAX_EXECUTION_TIME=30
ENV PHP_UPLOAD_MAX_FILESIZE=120M
ENV PHP_POST_MAX_SIZE=128M
ENV PHP_SESSION_SAVE_HANDLER=files
ENV PHP_SESSION_SAVE_PATH=
ENV PHP_DISPLAY_ERRORS=0
ENV PHP_DISPLAY_STARTUP_ERRORS=0
FROM system-setup AS app-setup
COPY . /app
RUN chown -R nginx:nginx /app
COPY ./docker/main/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER nginx
WORKDIR /app
RUN composer install --prefer-dist --no-scripts --no-dev --optimize-autoloader
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
CMD curl -s -o /dev/null -f http://localhost/v4/index.json || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+33
View File
@@ -0,0 +1,33 @@
server {
listen 80;
server_name localhost;
root /app/public;
index index.php;
error_log /dev/stdout;
access_log /dev/stdout;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass localhost:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
internal;
}
location ~ \.php$ {
return 404;
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -e
# Check if migrations need to be applied
if ! php bin/console doctrine:migrations:up-to-date --quiet; then
echo "Applying migrations..."
php bin/console doctrine:migrations:migrate --no-interaction
fi
# Check if FORCE_SCHEMA_UPDATE is set to true
if [ "$FORCE_SCHEMA_UPDATE" = "true" ]; then
echo "Forcing schema update..."
php bin/console doctrine:schema:update --force
fi
php bin/console cache:warmup
php bin/console assets:install
echo "Start Supervisord"
supervisord -c /etc/supervisord.conf
+9
View File
@@ -0,0 +1,9 @@
memory_limit = ${PHP_MEMORY_LIMIT}
max_execution_time = ${PHP_MAX_EXECUTION_TIME}
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE}
post_max_size = ${PHP_POST_MAX_SIZE}
allow_url_fopen = 1
session.save_handler = ${PHP_SESSION_SAVE_HANDLER}
session.save_path = ${PHP_SESSION_SAVE_PATH}
display_errors = ${PHP_DISPLAY_ERRORS}
display_startup_errors = ${PHP_DISPLAY_STARTUP_ERRORS}
+26
View File
@@ -0,0 +1,26 @@
[supervisord]
logfile=/dev/null
logfile_maxbytes=0
logfile_backups=0
loglevel=info
nodaemon=true
[program:php-fpm]
command=php-fpm --nodaemonize
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
[program:nginx]
command=nginx -g 'daemon off;'
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
+18
View File
@@ -0,0 +1,18 @@
[global]
daemonize = no
[www]
user = nginx
group = nginx
listen = 9000
listen.owner = nginx
listen.group = nginx
listen.mode = 0660
pm = ${FPM_PM}
pm.max_children = ${FPM_PM_MAX_CHILDREN}
pm.start_servers = ${FPM_PM_START_SERVERS}
pm.min_spare_servers = ${FPM_PM_MIN_SPARE_SERVERS}
pm.max_spare_servers = ${FPM_PM_MAX_SPARE_SERVERS}
catch_workers_output = yes
@@ -4,18 +4,17 @@ namespace App\ApiResource\Accounting;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Matchfunding\MatchCallApiResource;
use App\ApiResource\Project\ProjectApiResource;
use App\ApiResource\TipjarApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Accounting\Accounting;
use App\Entity\Money;
use App\Entity\Matchfunding\MatchCall;
use App\Entity\Project\Project;
use App\Entity\Tipjar;
use App\Entity\User\User;
use App\Mapping\Transformer\AccountingBalanceMapTransformer;
use App\State\Accounting\AccountingStateProcessor;
use App\State\Accounting\AccountingStateProvider;
use AutoMapper\Attribute\MapFrom;
/**
* v4 features an advanced economy model under the hood.
@@ -44,16 +43,21 @@ class AccountingApiResource
*/
public string $currency;
/**
* The money currently held by the Accounting.
*/
#[MapFrom(Accounting::class, transformer: AccountingBalanceMapTransformer::class)]
#[API\ApiProperty(writable: false, security: 'is_granted("ACCOUNTING_VIEW", object)')]
public Money $balance;
#[API\ApiProperty(readable: false, writable: false)]
public string $ownerClass;
#[API\ApiProperty(readable: false, writable: false)]
public ?UserApiResource $user = null;
#[API\ApiProperty(readable: false, writable: false)]
public ?ProjectApiResource $project = null;
#[API\ApiProperty(readable: false, writable: false)]
public ?TipjarApiResource $tipjar = null;
#[API\ApiProperty(readable: false, writable: false)]
public ?MatchCallApiResource $matchCall;
/**
* The resource owning this Accounting.
*
@@ -68,17 +72,10 @@ class AccountingApiResource
return $this->project;
case Tipjar::class:
return $this->tipjar;
case MatchCall::class:
return $this->matchCall;
}
return null;
}
#[API\ApiProperty(readable: false, writable: false)]
public ?UserApiResource $user = null;
#[API\ApiProperty(readable: false, writable: false)]
public ?ProjectApiResource $project = null;
#[API\ApiProperty(readable: false, writable: false)]
public ?TipjarApiResource $tipjar = null;
}
@@ -0,0 +1,38 @@
<?php
namespace App\ApiResource\Accounting;
use ApiPlatform\Metadata as API;
use App\ApiResource\ApiMoney;
use App\State\Accounting\AccountingBalanceStateProvider;
/**
* The AccountingBalance represents how much Money is currently held under one Accounting.\
* \
* This is calculated from incoming vs outgoing Transactions in the Accounting's history.
*/
#[API\ApiResource(
shortName: 'AccountingBalance',
)]
#[API\Get(
provider: AccountingBalanceStateProvider::class,
uriTemplate: '/accountings/{id}/balance',
uriVariables: [
'id' => new API\Link(
fromClass: AccountingApiResource::class,
fromProperty: 'id',
description: 'Accounting identifier'
),
]
)]
class AccountingBalance
{
#[API\ApiProperty(identifier: true)]
public AccountingApiResource $accounting;
/**
* The money currently held by the Accounting.
*/
#[API\ApiProperty(security: 'is_granted("ACCOUNTING_VIEW", object.accounting)')]
public ApiMoney $balance;
}
@@ -4,7 +4,7 @@ namespace App\ApiResource\Accounting;
use ApiPlatform\Metadata as API;
use ApiPlatform\Metadata\QueryParameter;
use App\Entity\Money;
use App\ApiResource\ApiMoney;
use App\State\Accounting\AccountingBalancePointStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
@@ -57,7 +57,7 @@ class AccountingBalancePoint
/**
* Resulting balance for items in this point.
*/
public Money $balance;
public ApiMoney $balance;
/**
* The number of items aggregated in this point.
@@ -5,8 +5,8 @@ namespace App\ApiResource\Accounting;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\ApiMoney;
use App\Entity\Accounting\Transaction;
use App\Entity\Money;
use App\State\ApiResourceStateProvider;
/**
@@ -33,7 +33,7 @@ class TransactionApiResource
/**
* The monetary value received at target and issued at origin.
*/
public Money $money;
public ApiMoney $money;
/**
* The Accounting from which the Transaction comes from.
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\ApiResource;
use ApiPlatform\Metadata as API;
use App\Money\Conversion\Conversion;
use App\Money\MoneyInterface;
use Symfony\Component\Validator\Constraints as Assert;
class ApiMoney implements MoneyInterface
{
/**
* An amount of currency.\
* Expressed as the minor unit, e.g: cents, pennies, etc.
*/
#[Assert\NotBlank()]
#[Assert\Positive()]
public int $amount;
/**
* 3-letter ISO 4217 currency code.
*/
#[Assert\NotBlank()]
#[Assert\Currency()]
public string $currency;
/**
* Conversion metadata.
*/
#[API\ApiProperty(readable: true, writable: false)]
public ?array $conversion = null;
public function __construct(
int $amount,
string $currency,
) {
$this->amount = $amount;
$this->currency = $currency;
}
public static function of(MoneyInterface $moneyInterface): self
{
$money = new self(
$moneyInterface->getAmount(),
$moneyInterface->getCurrency(),
);
$money->conversion = $moneyInterface->getConversion()?->toArray();
return $money;
}
public function getAmount(): int
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
public function getConversion(): ?Conversion
{
return $this->conversion
? Conversion::fromArray($this->conversion)
: null;
}
}
+12 -4
View File
@@ -6,9 +6,10 @@ use ApiPlatform\Doctrine\Orm\Filter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\AccountingApiResource;
use App\ApiResource\ApiMoney;
use App\Dto\Gateway\ChargeUpdationDto;
use App\Entity\Gateway\Charge;
use App\Entity\Money;
use App\Filter\GatewayFilter;
use App\Gateway\ChargeStatus;
use App\Gateway\ChargeType;
use App\State\ApiResourceStateProvider;
@@ -38,6 +39,8 @@ class ChargeApiResource
* The Checkout to which this Charge item belongs to.
*/
#[API\ApiProperty(writable: false, security: 'is_granted("IS_AUTHENTICATED_FULLY")')]
#[API\ApiFilter(GatewayFilter::class, properties: ['checkout.gateway'])]
#[API\ApiFilter(Filter\SearchFilter::class, properties: ['checkout.trackings.value'])]
public CheckoutApiResource $checkout;
/**
@@ -76,17 +79,22 @@ class ChargeApiResource
* It is money before fees and taxes, not accountable.
*/
#[Assert\NotBlank()]
#[API\ApiFilter(Filter\SearchFilter::class, properties: ['money.amount' => 'exact'])]
#[API\ApiFilter(Filter\RangeFilter::class, properties: ['money.amount'])]
public Money $money;
#[API\ApiFilter(Filter\SearchFilter::class, properties: ['money.currency' => 'exact'])]
public ApiMoney $money;
/**
* The status of the charge item with the Gateway.
*/
#[Assert\NotBlank()]
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(Filter\SearchFilter::class, strategy: 'exact')]
public ChargeStatus $status = ChargeStatus::InPending;
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(Filter\DateFilter::class)]
public \DateTimeInterface $dateCreated;
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(Filter\DateFilter::class)]
public \DateTimeInterface $dateUpdated;
}
@@ -5,7 +5,7 @@ namespace App\ApiResource\Gateway;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\AccountingApiResource;
use App\Dto\CheckoutUpdationDto;
use App\Dto\Gateway\CheckoutUpdationDto;
use App\Entity\Gateway\Checkout;
use App\Gateway\CheckoutStatus;
use App\Gateway\Link;
@@ -60,7 +60,7 @@ class CheckoutApiResource
*
* @var ChargeApiResource[]
*/
#[API\ApiProperty(readableLink: true, writableLink: true)]
#[API\ApiProperty(readableLink: true)]
#[Assert\NotBlank()]
#[Assert\Count(min: 1)]
public array $charges = [];
@@ -106,20 +106,16 @@ class CheckoutApiResource
* @var Tracking[]
*/
#[API\ApiProperty(writable: false)]
#[MapTo(Checkout::class, transformer: 'parseTrackings')]
#[MapFrom(Checkout::class, transformer: 'parseTrackings')]
public array $trackings = [];
#[API\ApiProperty(writable: false)]
public \DateTimeInterface $dateCreated;
#[API\ApiProperty(writable: false)]
public \DateTimeInterface $dateUpdated;
public static function parseLinks(array $values)
{
return \array_map(fn($value) => Link::tryFrom($value), $values);
}
public static function parseTrackings(array $values)
{
return \array_map(fn($value) => Tracking::tryFrom($value), $values);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\ApiResource\Matchfunding;
use ApiPlatform\Metadata as API;
use App\Matchfunding\Formula\FormulaInterface;
use App\State\Matchfunding\FormulaStateProvider;
/**
* A MatchFormula is a predefined code implementation for matching funds in Transactions under a MatchCall.
* MatchFormulas can be chosen by the managers in a MatchCall and their behaviour fine-tuned.
*/
#[API\ApiResource(
shortName: 'MatchFormula',
provider: FormulaStateProvider::class
)]
#[API\GetCollection()]
#[API\Get()]
class FormulaApiResource
{
#[API\ApiProperty(identifier: true, writable: false)]
public string $name;
/**
* The underlying math expressed as a common-notation formula.
*/
#[API\ApiProperty(writable: false)]
public string $expression;
public static function from(FormulaInterface $interface): FormulaApiResource
{
$resource = new FormulaApiResource();
$resource->name = $interface::getName();
$resource->expression = $interface::getAsExpression();
return $resource;
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\ApiResource\Matchfunding;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\AccountingApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Matchfunding\MatchCall;
use App\Entity\Matchfunding\MatchCallStatus;
use App\Entity\Territory;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A MatchCall is a managed event which accepts MatchCallSubmissions from Projects to receive *matchfunding* financement.
* This means money going to a Project in a MatchCall can be matched with funds from the MatchCall accounting.
* \
* \
* MatchCallSubmissions from Projects can be accepted or rejected by the managers.
* \
* \
* How and when does a MatchCall match funds is determined by the MatchStrategy.
* Everytime there is a Charge item going to a Project accepted in a MatchCall the strategy is evaluated for matching.
* The strategy defines a series of rules that determine if the Charge is eligible for triggering the matching of funds.
* When a Charge is to be matched the strategy uses a MatchFormula,
* which is a predefined implementation for common mathematical operations used to match money.
* The formula is fine-tuned in the strategy by defining the variables in the formula.
*/
#[API\ApiResource(
shortName: 'MatchCall',
stateOptions: new Options(entityClass: MatchCall::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class
)]
class MatchCallApiResource
{
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
/**
* The Accounting which holds and spends the funds for this MatchCall.
*/
#[API\ApiProperty(writable: false)]
public AccountingApiResource $accounting;
/**
* The MatchStrategies define the matching behaviour for this MatchCall.
*
* @var MatchStrategyApiResource[]
*/
#[API\ApiProperty(writable: false)]
public array $strategies;
/**
* A list of the MatchCallSubmissions received by this MatchCall.
*
* @var MatchCallSubmissionApiResource[]
*/
#[API\ApiProperty(writable: false)]
public array $submissions;
/**
* A list of Users who can modify this MatchCall.
*
* @var UserApiResource[]
*/
public array $managers;
/**
* Main display title.
*/
#[Assert\NotBlank()]
public string $title;
/**
* Long-form secondary display text.
*/
public string $description;
/**
* Codes for the territory of interest in this MatchCall.
*/
#[Assert\NotBlank()]
#[Assert\Valid]
public Territory $territory;
/**
* The current status of the MatchCall.
*/
public MatchCallStatus $status = MatchCallStatus::InEditing;
}
@@ -0,0 +1,56 @@
<?php
namespace App\ApiResource\Matchfunding;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Project\ProjectApiResource;
use App\Dto\Matchfunding\MatchCallSubmissionCreationDto;
use App\Entity\Matchfunding\MatchCallSubmission;
use App\Entity\Matchfunding\MatchCallSubmissionStatus;
use App\State\ApiResourceStateProvider;
use App\State\Matchfunding\MatchCallSubmissionStateProcessor;
/**
* MatchCallSubmissions represent the will of a Project to be held under a MatchCall and receive matchfunding financement.
*/
#[API\ApiResource(
shortName: 'MatchCallSubmission',
stateOptions: new Options(entityClass: MatchCallSubmission::class),
provider: ApiResourceStateProvider::class,
processor: MatchCallSubmissionStateProcessor::class
)]
#[API\GetCollection()]
#[API\Post(
input: MatchCallSubmissionCreationDto::class,
securityPostDenormalize: 'is_granted("PROJECT_EDIT", object.project)',
securityPostDenormalizeMessage: 'You do not have permission to submit that Project to a MatchCall'
)]
#[API\Get()]
#[API\Patch(
security: 'is_granted("MATCHCALL_EDIT", object.call)',
securityMessage: 'You do not have permission to edit this MatchCallSubmission'
)]
class MatchCallSubmissionApiResource
{
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
/**
* The MatchCall to which this MatchCallSubmission belongs to.
*/
#[API\ApiProperty(writable: false)]
public MatchCallApiResource $call;
/**
* The Project that applied for the MatchCall.
*/
#[API\ApiProperty(writable: false)]
public ProjectApiResource $project;
/**
* The status of the Project's application for the MatchCall.\
* Only MatchCallSubmissions with an status `accepted` will receive matchfunding.
*/
public MatchCallSubmissionStatus $status = MatchCallSubmissionStatus::DEFAULT;
}
@@ -0,0 +1,110 @@
<?php
namespace App\ApiResource\Matchfunding;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\ApiMoney;
use App\Entity\Matchfunding\MatchAgainst;
use App\Entity\Matchfunding\MatchStrategy;
use App\Mapping\Transformer\MatchFormulaMapTransformer;
use App\Mapping\Transformer\MatchRulesMapTransformer;
use App\State\ApiResourceStateProvider;
use App\State\Matchfunding\MatchStrategyStateProcessor;
use AutoMapper\Attribute\MapFrom;
use AutoMapper\Attribute\MapTo;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A MatchStrategy defines how will a MatchCall perform the match-making process.
* \
* The match-making flow is:
* 1. A Project accepted in a MatchCall receives a successful Charge.
* 2. The match-making loads the MatchCall's MatchStrategies.
* 3. For each MatchStrategy the rules are executed, if one rule fails the match-making is cancelled for the strategy.
* 3. The first valid MatchStrategy's formula function is passed the respective limit, factor and money of the MatchAgainst.
* 4. The result of the MatchStrategy formula function execution is put in a Transaction from the MatchCall to the Project.
*/
#[API\ApiResource(
shortName: 'MatchStrategy',
stateOptions: new Options(entityClass: MatchStrategy::class),
provider: ApiResourceStateProvider::class,
processor: MatchStrategyStateProcessor::class,
)]
#[API\GetCollection()]
#[API\Post()]
#[API\Get()]
#[API\Patch(security: 'is_granted("MATCHCALL_EDIT", object.call)')]
#[API\Delete(security: 'is_granted("MATCHCALL_EDIT", object.call)')]
class MatchStrategyApiResource
{
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
/**
* The MatchCall to which this strategy belongs to.
*/
#[Assert\NotBlank()]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public MatchCallApiResource $call;
/**
* The ranking is the index order of this strategy among the others of the MatchCall
* when this is up for match making. The ranking value of items will be sorted
* after each change in the number of strategies or the ranking values.
*/
#[Assert\PositiveOrZero()]
#[API\ApiFilter(OrderFilter::class)]
public int $ranking = 0;
/**
* The MatchRules used to decide if the match making strategy should be executed or not.
*
* @var RuleApiResource[]
*/
#[MapTo(MatchStrategy::class, property: 'ruleNames', transformer: [self::class, 'rulesToNames'])]
#[MapFrom(MatchStrategy::class, property: 'ruleNames', transformer: MatchRulesMapTransformer::class)]
public array $rules;
/**
* The MatchFormula used to calculate matched funds.
*/
#[Assert\NotBlank()]
#[MapTo(MatchStrategy::class, property: 'formulaName', transformer: 'source.formula.name')]
#[MapFrom(MatchStrategy::class, property: 'formulaName', transformer: MatchFormulaMapTransformer::class)]
public FormulaApiResource $formula;
/**
* The assigned maximum amount of funding that will be given by the MatchFormula per operation.
*/
#[Assert\NotBlank()]
#[Assert\Valid()]
public ApiMoney $limit;
/**
* The `x` factor used to calculate the resulting match of funds with the MatchFormula.
*/
#[Assert\NotBlank()]
#[Assert\Positive()]
public float $factor;
/**
* The money to be matched by the formula is
* - `charge` the money in the Charge item
* - `budget_min` the minimum in the Project's budget
* - `budget_opt` the optimum in the Project's budget.
*/
public MatchAgainst $against = MatchAgainst::DEFAULT;
/**
* @param array<int, RuleApiResource>
*
* @return array<string>
*/
public static function rulesToNames(array $values)
{
return \array_map(fn($r) => $r->name, $values);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\ApiResource\Matchfunding;
use ApiPlatform\Metadata as API;
use App\Matchfunding\Rule\RuleInterface;
use App\State\Matchfunding\RuleStateProvider;
/**
* A MatchRule is a predefined code implementation for validating
* that a match making condition is met when there is a Charge to be matched.
*/
#[API\ApiResource(
shortName: 'MatchRule',
provider: RuleStateProvider::class
)]
#[API\GetCollection()]
#[API\Get()]
class RuleApiResource
{
#[API\ApiProperty(identifier: true, writable: false)]
public string $name;
/**
* A plain-text description about what the rules validates for.
*/
#[API\ApiProperty(writable: false)]
public string $description;
public static function from(RuleInterface $interface): RuleApiResource
{
$resource = new RuleApiResource();
$resource->name = \array_slice(\explode('\\', $interface::class), -1, 1)[0];
$resource->description = $interface::getDescription();
return $resource;
}
}
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace App\ApiResource;
use Symfony\Component\Validator\Constraints as Assert;
class Money
{
/**
* An amount of currency.\
* Expressed as the minor unit, e.g: cents, pennies, etc.
*/
#[Assert\NotBlank()]
#[Assert\Positive()]
public int $amount;
/**
* 3-letter ISO 4217 currency code.
*/
#[Assert\NotBlank()]
#[Assert\Currency()]
public string $currency;
}
@@ -5,8 +5,8 @@ namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\ApiMoney;
use App\ApiResource\LocalizedApiResourceTrait;
use App\ApiResource\Money;
use App\Entity\Project\BudgetItem;
use App\Entity\Project\BudgetItemType;
use App\Entity\Project\ProjectDeadline;
@@ -58,7 +58,7 @@ class BudgetItemApiResource
*/
#[Assert\NotBlank()]
#[Assert\Valid()]
public Money $money;
public ApiMoney $money;
/**
* Defines the budget category for this item within the project.
+5 -5
View File
@@ -2,27 +2,27 @@
namespace App\ApiResource\Project;
use App\Entity\Money;
use App\ApiResource\ApiMoney;
class BudgetSummary
{
/**
* The total money by the included items.
*/
public Money $money;
public ApiMoney $money;
/**
* The total money of type 'task'.
*/
public Money $task;
public ApiMoney $task;
/**
* The total money of type 'material'.
*/
public Money $material;
public ApiMoney $material;
/**
* The total money of type 'infrastructure'.
*/
public Money $infra;
public ApiMoney $infra;
}
@@ -16,6 +16,7 @@ use App\Entity\Project\ProjectCategory;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\ProjectVideo;
use App\Entity\Territory;
use App\Mapping\Transformer\BudgetMapTransformer;
use App\State\ApiResourceStateProvider;
use App\State\Project\ProjectStateProcessor;
@@ -118,7 +119,7 @@ class ProjectApiResource
*/
#[Assert\NotBlank()]
#[Assert\Valid()]
public ProjectTerritoryApiResource $territory;
public Territory $territory;
/**
* Free-form rich text description for the Project.
@@ -1,33 +0,0 @@
<?php
namespace App\ApiResource\Project;
use App\Validator\CountrySubdivision;
use Symfony\Component\Validator\Constraints as Assert;
/**
* ISO 3166 data about the Project's territory of interest.
*/
class ProjectTerritoryApiResource
{
/**
* ISO 3166-1 alpha-2 two-letter country code.
*/
#[Assert\NotBlank()]
#[Assert\Country(alpha3: false)]
public string $country;
/**
* ISO 3166-2 first level subdivision code.\
* e.g: ES-AN (Andalucía, Spain).
*/
#[CountrySubdivision()]
public string $subLvl1;
/**
* ISO 3166-2 second level subdivision code.\
* e.g: ES-GR (Granada, Andalucía, Spain).
*/
#[CountrySubdivision()]
public string $subLvl2;
}
+20 -11
View File
@@ -2,11 +2,13 @@
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\ApiMoney;
use App\ApiResource\LocalizedApiResourceTrait;
use App\Entity\Money;
use App\Entity\Project\Reward;
use App\State\ApiResourceStateProvider;
use App\State\Project\RewardStateProcessor;
@@ -53,28 +55,35 @@ class RewardApiResource
* The minimal monetary sum to be able to claim this reward.
*/
#[Assert\NotBlank()]
public Money $money;
#[API\ApiFilter(OrderFilter::class, properties: ['money.amount'])]
public ApiMoney $money;
/**
* Rewards might be finite, i.e: has a limited amount of existing unitsTotal.
*/
#[Assert\NotNull()]
#[Assert\Type('bool')]
public bool $hasUnits;
#[API\ApiFilter(BooleanFilter::class)]
public bool $isFinite = false;
/**
* For finite rewards, the total amount of existing unitsTotal.\
* Required if `hasUnits`.
* For finite rewards, the total amount of existing units.\
* Required if `isFinite`.
*/
#[Assert\When(
'this.hasUnits == true',
constraints: [new Assert\Positive()]
)]
public int $unitsTotal = 0;
#[Assert\When('this.isFinite == true', [new Assert\Positive()])]
public ?int $unitsTotal = null;
/**
* For finite rewards, the currently available amount of unitsTotal that can be claimed.
* The total amount of claims on this Reward.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(OrderFilter::class)]
public int $unitsClaimed = 0;
/**
* For finite rewards, the currently available amount of units that can be claimed.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(OrderFilter::class)]
public int $unitsAvailable = 0;
}
@@ -2,15 +2,16 @@
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Gateway\ChargeApiResource;
use App\ApiResource\User\UserApiResource;
use App\Dto\RewardClaimCreationDto;
use App\Entity\Project\RewardClaim;
use App\State\ApiResourceStateProvider;
use App\State\Project\RewardClaimStateProcessor;
use App\Validator\AvailableRewardUnits;
use App\Validator\EnoughRewardCharge;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -22,7 +23,10 @@ use Symfony\Component\Validator\Constraints as Assert;
provider: ApiResourceStateProvider::class,
processor: RewardClaimStateProcessor::class
)]
#[EnoughRewardCharge()]
#[API\GetCollection()]
#[API\Post(input: RewardClaimCreationDto::class)]
#[API\Get()]
#[API\Delete()]
class RewardClaimApiResource
{
#[API\ApiProperty(identifier: true, writable: false)]
@@ -32,12 +36,14 @@ class RewardClaimApiResource
* The User claiming the ProjectReward.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public UserApiResource $owner;
/**
* The GatewayCharge granting access to the ProjectReward.
*/
#[Assert\NotBlank()]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ChargeApiResource $charge;
/**
@@ -45,5 +51,6 @@ class RewardClaimApiResource
*/
#[Assert\NotBlank()]
#[AvailableRewardUnits()]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public RewardApiResource $reward;
}
+52 -23
View File
@@ -2,25 +2,54 @@
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Gateway\ChargeApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Money;
use App\ApiResource\Accounting\AccountingApiResource;
use App\ApiResource\Accounting\TransactionApiResource;
use App\ApiResource\ApiMoney;
use App\Entity\Project\Support;
use App\Mapping\Transformer\SupportMoneyMapTransformer;
use App\Money\Totalization\TotalizedMoney;
use App\State\ApiResourceStateProvider;
use App\State\MoneyTotalStateProvider;
use App\State\Project\SupportStateProcessor;
use AutoMapper\Attribute\MapFrom;
use Symfony\Component\Validator\Constraints as Assert;
/**
* ProjectSupports gather Transactions going from one same origin to one same Project.\
* \
* MatchCalls specially might make several different Transactions to the same Project,
* but their ProjectSupport remains the same just with updated money.
*/
#[API\ApiResource(
shortName: 'ProjectSupport',
stateOptions: new Options(entityClass: Support::class),
provider: ApiResourceStateProvider::class,
)]
#[API\GetCollection()]
#[API\GetCollection(
uriTemplate: '/project_supports/money_total',
provider: MoneyTotalStateProvider::class,
output: TotalizedMoney::class,
paginationEnabled: false,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Get money total',
description: 'Returns a single TotalizedMoney object',
responses: [
'200' => [
'description' => 'Totalized money',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ProjectSupport.TotalizedMoney',
],
],
],
],
]
)
)]
#[API\Get()]
#[API\Patch(
security: 'is_granted("SUPPORT_EDIT", previous_object)',
@@ -32,40 +61,40 @@ class SupportApiResource
public int $id;
/**
* The User who created the ProjectSupport record.\
* \
* When `anonymous` is *true* it will only be public to admins and the User.
*/
#[API\ApiProperty(writable: false, security: 'is_granted("SUPPORT_VIEW", object)')]
#[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')]
public ?UserApiResource $owner;
/**
* The Project being targeted in the Charges.
* The Project being supported.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')]
public ProjectApiResource $project;
/**
* The Charges that were paid by the User.
*
* @var array<int, ChargeApiResource>
* The Accounting of origin for the Transactions under this ProjectSupport record.\
* \
* When `anonymous` is *true* it will only be public to admins and the User.
*/
#[API\ApiProperty(writable: false)]
public array $charges;
#[API\ApiProperty(writable: false, security: 'is_granted("SUPPORT_VIEW", object)')]
#[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')]
public ?AccountingApiResource $origin;
/**
* The total monetary value of the Charges paid by the User.
* The Transactions that were issued to the Project by the origin.
*
* @var array<int, TransactionApiResource>
*/
#[MapFrom(Support::class, transformer: SupportMoneyMapTransformer::class)]
public Money $money;
#[API\ApiProperty(writable: false)]
public array $transactions;
/**
* The total monetary value of the Transactions going to the Project.
*/
public ApiMoney $money;
/**
* User's will to have their support to the Project be shown publicly.
*/
#[Assert\NotNull()]
#[Assert\Type('bool')]
#[API\ApiFilter(BooleanFilter::class)]
public bool $anonymous = true;
/**
+2
View File
@@ -2,6 +2,7 @@
namespace App\ApiResource;
use ApiPlatform\Doctrine\Orm\Filter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\AccountingApiResource;
@@ -38,5 +39,6 @@ class TipjarApiResource
* Human readable, non white space, unique string.
*/
#[Assert\NotBlank()]
#[API\ApiFilter(Filter\SearchFilter::class, strategy: 'partial')]
public string $name;
}
-248
View File
@@ -1,248 +0,0 @@
<?php
namespace App\Benzina;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Money;
use App\Entity\Project\Project;
use App\Entity\Tipjar;
use App\Entity\User\User;
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\Repository\Project\ProjectRepository;
use App\Repository\TipjarRepository;
use App\Repository\User\UserRepository;
use App\Service\Gateway\CheckoutService;
use Goteo\Benzina\Pump\ArrayPumpTrait;
use Goteo\Benzina\Pump\DoctrinePumpTrait;
use Goteo\Benzina\Pump\PumpInterface;
class CheckoutsPump implements PumpInterface
{
use ArrayPumpTrait;
use DoctrinePumpTrait;
use CheckoutsPumpTrait;
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 MAX_INT = 2147483647;
public function __construct(
private UserRepository $userRepository,
private ProjectRepository $projectRepository,
private TipjarRepository $tipjarRepository,
private CheckoutService $checkoutService,
) {}
public function supports(mixed $sample): bool
{
if ($this->hasAllKeys($sample, self::INVEST_KEYS)) {
return true;
}
return false;
}
public function pump(mixed $record, array $context): void
{
if (!$record['user'] || empty($record['user'])) {
return;
}
if (!$record['amount'] || $record['amount'] < 1) {
return;
}
if (!$record['method'] || empty($record['method'])) {
return;
}
$user = $this->getUser($record);
if ($user === null) {
return;
}
$project = $this->getProject($record);
$tipjar = $this->getPlatformTipjar();
$checkout = new Checkout();
$checkout->setOrigin($user->getAccounting());
$checkout->setStatus($this->getCheckoutStatus($record));
$checkout->setGatewayName($this->getCheckoutGateway($record));
foreach ($this->getCheckoutTrackings($record) as $tracking) {
$checkout->addTracking($tracking);
}
$checkout->setMigrated(true);
$checkout->setMigratedId($record['id']);
$checkout->setDateCreated(new \DateTime($record['invested']));
$checkout->setDateUpdated(new \DateTime());
$charge = new Charge();
$charge->setType($this->getChargeType($record));
$charge->setMoney($this->getChargeMoney($record['amount'], $record['currency']));
if ($project === null) {
$charge->setTitle(self::CHARGE_TITLE_POOL);
$charge->setTarget($user->getAccounting());
} else {
$charge->setTitle(self::CHARGE_TITLE_PROJECT);
$charge->setTarget($project->getAccounting());
}
$checkout->addCharge($charge);
if ($record['donate_amount'] > 0) {
$tip = new Charge();
$tip->setType(ChargeType::Single);
$tip->setTitle(self::CHARGE_TITLE_TIP);
$tip->setMoney($this->getChargeMoney($record['donate_amount'], $record['currency']));
$tip->setTarget($tipjar->getAccounting());
$checkout->addCharge($tip);
}
if ($checkout->getStatus() === CheckoutStatus::Charged) {
$checkout = $this->checkoutService->chargeCheckout($checkout);
}
$this->persist($checkout, $context);
}
private function getUser(array $record): ?User
{
return $this->userRepository->findOneBy(['migratedId' => $record['user']]);
}
private function getProject(array $record): ?Project
{
if (empty($record['project'])) {
return null;
}
return $this->projectRepository->findOneBy(['migratedId' => $record['project']]);
}
private function getPlatformTipjar(): Tipjar
{
$tipjar = $this->tipjarRepository->findOneBy(['name' => self::PLATFORM_TIPJAR_NAME]);
if ($tipjar) {
return $tipjar;
}
$tipjar = new Tipjar();
$tipjar->setName(self::PLATFORM_TIPJAR_NAME);
$this->entityManager->persist($tipjar);
$this->entityManager->flush();
return $tipjar;
}
private function getChargeType(array $record): ChargeType
{
if (\in_array($record['method'], ['stripe_subscription'])) {
return ChargeType::Recurring;
}
return ChargeType::Single;
}
private function getChargeMoney(int $amount, string $currency): Money
{
$amount = $amount * 100;
if ($amount >= self::MAX_INT) {
$amount = self::MAX_INT;
}
return new Money($amount, $currency);
}
private function getCheckoutStatus(array $record): CheckoutStatus
{
if ($record['status'] < 1) {
return CheckoutStatus::InPending;
}
return CheckoutStatus::Charged;
}
private function getCheckoutGateway(array $record): string
{
switch ($record['method']) {
case 'stripe_subscription':
return StripeGateway::getName();
case 'pool':
return WalletGateway::getName();
case 'paypal':
return PaypalGateway::getName();
case 'tpv':
return CecaGateway::getName();
case 'cash':
return CashGateway::getName();
case 'drop':
return DropGateway::getName();
default:
return '';
}
}
/**
* @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'])) {
$payment = new Tracking();
$payment->title = self::TRACKING_TITLE_PAYMENT;
$payment->value = $record['payment'];
$trackings[] = $payment;
}
if (!empty($record['transaction'])) {
$transaction = new Tracking();
$transaction->title = self::TRACKING_TITLE_TRANSACTION;
$transaction->value = $record['transaction'];
$trackings[] = $transaction;
}
if (!empty($record['preapproval'])) {
$preapproval = new Tracking();
$preapproval->title = self::TRACKING_TITLE_PREAPPROVAL;
$preapproval->value = $record['preapproval'];
$trackings[] = $preapproval;
}
return $trackings;
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Benzina;
use App\Entity\Gateway\Charge;
use App\Entity\Project\Reward;
use App\Entity\Project\RewardClaim;
use App\Entity\User\User;
use App\Gateway\ChargeStatus;
use App\Money\MoneyService;
use App\Repository\Gateway\ChargeRepository;
use App\Repository\Project\RewardRepository;
use App\Repository\User\UserRepository;
use Goteo\Benzina\Pump\ArrayPumpTrait;
use Goteo\Benzina\Pump\DoctrinePumpTrait;
use Goteo\Benzina\Pump\PumpInterface;
class InvestRewardsPump implements PumpInterface
{
use ArrayPumpTrait;
use DatabasePumpTrait;
use DoctrinePumpTrait;
use InvestsPumpTrait;
/** @var array<string, int> */
private array $userCache = [];
/** @var array<string, int> */
private array $chargeCache = [];
/** @var array<string, int> */
private array $rewardCache = [];
public function __construct(
private RewardRepository $rewardRepository,
private UserRepository $userRepository,
private ChargeRepository $chargeRepository,
private MoneyService $moneyService,
) {}
public function supports(mixed $sample): bool
{
if ($this->hasAllKeys($sample, ['invest', 'reward', 'fulfilled'])) {
return true;
}
return false;
}
public function pump(mixed $record, array $context): void
{
$reward = $this->getReward($record);
if ($reward === null) {
return;
}
$invest = $this->getInvest($record, $context);
if ($invest === null) {
return;
}
$charge = $this->getCharge($invest);
if ($charge === null) {
return;
}
$user = $this->getUser($invest);
if ($user === null) {
return;
}
if ($charge->getStatus() !== ChargeStatus::Charged) {
return;
}
$claim = new RewardClaim();
$claim->setOwner($user);
$claim->setCharge($charge);
$reward->addClaim($claim);
$claim->setReward($reward);
$this->persist($claim, $context);
}
private function getInvest(array $record, array $context): ?array
{
$query = $this->getDbConnection($context)->prepare(
'SELECT * FROM `invest` i WHERE i.id = :invest'
);
$query->execute(['invest' => $record['invest']]);
$result = $query->fetch(\PDO::FETCH_ASSOC);
if (!$result) {
return null;
}
return $result;
}
private function getReward(array $record): ?Reward
{
$id = $record['reward'];
if (isset($this->rewardCache[$id])) {
return $this->rewardRepository->find($this->rewardCache[$id]);
}
$reward = $this->rewardRepository->findOneBy(['migratedId' => $id]);
if (!$reward) {
return null;
}
$this->rewardCache[$id] = $reward->getId();
return $reward;
}
private function getCharge(array $invest): ?Charge
{
$id = $invest['id'];
if (isset($this->chargeCache[$id])) {
return $this->chargeRepository->find($this->chargeCache[$id]);
}
$charge = $this->chargeRepository->findOneBy(['migratedId' => $id]);
if (!$charge) {
return null;
}
$this->chargeCache[$id] = $charge->getId();
return $charge;
}
private function getUser(array $invest): ?User
{
$id = $invest['user'];
if (isset($this->userCache[$id])) {
return $this->userRepository->find($this->userCache[$id]);
}
$user = $this->userRepository->findOneBy(['migratedId' => $id]);
$this->userCache[$id] = $user->getId();
return $user;
}
}
+502
View File
@@ -0,0 +1,502 @@
<?php
namespace App\Benzina;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Gateway\Tracking;
use App\Entity\Project\Project;
use App\Entity\Project\Reward;
use App\Entity\Project\Support;
use App\Entity\Tipjar;
use App\Entity\User\User;
use App\Gateway\ChargeStatus;
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\Wallet\WalletGateway;
use App\Money\Money;
use App\Money\MoneyService;
use App\Repository\Project\ProjectRepository;
use App\Repository\Project\RewardRepository;
use App\Repository\Project\SupportRepository;
use App\Repository\TipjarRepository;
use App\Repository\User\UserRepository;
use App\Service\Gateway\CheckoutService;
use Goteo\Benzina\Pump\ArrayPumpTrait;
use Goteo\Benzina\Pump\DoctrinePumpTrait;
use Goteo\Benzina\Pump\PumpInterface;
class InvestsPump implements PumpInterface
{
use ArrayPumpTrait;
use DatabasePumpTrait;
use DoctrinePumpTrait;
use InvestsPumpTrait;
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_RETURN_URL = 'https://goteo.org';
private const PLATFORM_TIPJAR_NAME = 'platform';
private const MAX_INT = 2147483647;
private const CURRENCY = 'EUR';
private ?int $tipjarCache = null;
/** @var array<string, int> */
private array $userCache = [];
/** @var array<string, int> */
private array $projectCache = [];
/** @var array<string, int> */
private array $supportCache = [];
/** @var array<string, int> */
private array $rewardCache = [];
public function __construct(
private UserRepository $userRepository,
private ProjectRepository $projectRepository,
private SupportRepository $supportRepository,
private TipjarRepository $tipjarRepository,
private RewardRepository $rewardRepository,
private CheckoutService $checkoutService,
private MoneyService $moneyService,
) {}
public function supports(mixed $sample): bool
{
if ($this->hasAllKeys($sample, self::INVEST_KEYS)) {
return true;
}
return false;
}
public function pump(mixed $record, array $context): void
{
if (!$record['user'] || empty($record['user'])) {
return;
}
if (!$record['amount'] || $record['amount'] < 1) {
return;
}
if (!$record['method'] || empty($record['method'])) {
return;
}
$user = $this->getUser($record);
if ($user === null) {
return;
}
$project = $this->getProject($record);
$tipjar = $this->getPlatformTipjar();
$now = new \DateTime();
$invested = new \DateTime($record['datetime'] ?? $record['invested']);
$checkout = new Checkout();
$checkout->setMigrated(true);
$checkout->setMigratedId($record['id']);
$checkout->setDateCreated($invested);
$checkout->setDateUpdated($now);
$checkout->setOrigin($user->getAccounting());
$checkout->setStatus($this->getCheckoutStatus($record));
$checkout->setGatewayName($this->getCheckoutGateway($record));
$checkout->setReturnUrl(self::PLATFORM_RETURN_URL);
foreach ($this->getCheckoutTrackings($record) as $tracking) {
$checkout->addTracking($tracking);
}
$charge = new Charge();
$charge->setMigrated(true);
$charge->setMigratedId($record['id']);
$charge->setDateCreated($invested);
$charge->setDateUpdated($now);
$charge->setStatus($this->getChargeStatus($record));
$charge->setType($this->getChargeType($record));
$charge->setMoney($this->getChargeMoney($record['amount'], self::CURRENCY));
if ($project === null) {
$charge->setTitle(self::CHARGE_TITLE_POOL);
$charge->setTarget($user->getAccounting());
} else {
$charge->setTitle(self::CHARGE_TITLE_PROJECT);
$charge->setTarget($project->getAccounting());
}
$checkout->addCharge($charge);
if ($record['donate_amount'] > 0) {
$tip = new Charge();
$tip->setMigrated(true);
$tip->setMigratedId($record['id']);
$tip->setDateCreated($invested);
$tip->setDateUpdated($now);
$tip->setStatus($this->getChargeStatus($record));
$tip->setType(ChargeType::Single);
$tip->setTitle(self::CHARGE_TITLE_TIP);
$tip->setMoney($this->getChargeMoney($record['donate_amount'], self::CURRENCY));
$tip->setTarget($tipjar->getAccounting());
$checkout->addCharge($tip);
}
foreach ($checkout->getCharges() as $charge) {
if (!\in_array($charge->getStatus(), [ChargeStatus::Charged, ChargeStatus::Refunded])) {
continue;
}
$transaction = new Transaction();
$transaction->setDateCreated($invested);
$transaction->setMoney($charge->getMoney());
$transaction->setOrigin($checkout->getOrigin());
$transaction->setTarget($charge->getTarget());
$charge->addTransaction($transaction);
if (
$charge->getTarget()->getOwner() instanceof Project
&& $charge->getStatus() === ChargeStatus::Charged
) {
$support = $this->getSupport($charge);
$support->setProject($charge->getTarget()->getOwner());
$support->setOrigin($checkout->getOrigin());
$support->addTransaction($transaction);
$support->setMoney($this->calcSupportMoney($support));
$support->setAnonymous($this->getSupportAnon($record, $support));
$support->setMessage($this->getSupportMessage($record, $support, $context));
$this->entityManager->persist($support);
}
if ($charge->getStatus() !== ChargeStatus::Refunded) {
continue;
}
$returned = new Transaction();
$returned->setDateCreated(new \DateTime($record['returned'] ?? 'now'));
$returned->setMoney($charge->getMoney());
$returned->setOrigin($charge->getTarget());
$returned->setTarget($checkout->getOrigin());
$charge->addTransaction($returned);
}
$this->persist($checkout, $context);
}
private function getUser(array $record): ?User
{
$id = $record['user'];
if (isset($this->userCache[$id])) {
return $this->userRepository->find($this->userCache[$id]);
}
$user = $this->userRepository->findOneBy(['migratedId' => $id]);
$this->userCache[$id] = $user->getId();
return $user;
}
private function getProject(array $record): ?Project
{
$id = $record['project'] ?? null;
if (!$id) {
return null;
}
if (isset($this->projectCache[$id])) {
return $this->projectRepository->find($this->projectCache[$id]);
}
$project = $this->projectRepository->findOneBy(['migratedId' => $id]);
if (!$project) {
return null;
}
$this->projectCache[$id] = $project->getId();
return $project;
}
private function getPlatformTipjar(): Tipjar
{
if ($this->tipjarCache !== null) {
return $this->tipjarRepository->find($this->tipjarCache);
}
$tipjar = $this->tipjarRepository->findOneBy(['name' => self::PLATFORM_TIPJAR_NAME]);
if ($tipjar) {
$this->tipjarCache = $tipjar->getId();
return $tipjar;
}
$tipjar = new Tipjar();
$tipjar->setName(self::PLATFORM_TIPJAR_NAME);
$this->entityManager->persist($tipjar);
$this->entityManager->flush();
return $tipjar;
}
private function getSupport(Charge $charge): ?Support
{
$origin = $charge->getCheckout()->getOrigin();
$target = $charge->getTarget()->getOwner();
$originId = $origin->getId();
$projectId = $target->getId();
$cacheKey = $projectId.'-'.$originId;
if (isset($this->supportCache[$cacheKey])) {
return $this->supportRepository->find($this->supportCache[$cacheKey]);
}
$support = $this->supportRepository->findOneBy([
'origin' => $origin,
'project' => $target,
]);
if ($support !== null) {
$this->supportCache[$cacheKey] = $support->getId();
return $support;
}
return new Support();
}
private function getSupportAnon(array $record, Support $support): bool
{
if ($record['anonymous'] === 1) {
return true;
}
if ($support->isAnonymous()) {
return true;
}
return false;
}
private function getSupportMessage(array $record, Support $support, array $context): ?string
{
$message = $support->getMessage();
$msg = $this->getInvestMsg($record, $context);
if (!$msg || empty($msg['msg'])) {
return $message;
}
if (!$message) {
return $msg['msg'];
}
return \sprintf("\n***\n%s", $msg['msg']);
}
private function calcSupportMoney(Support $support): EmbeddableMoney
{
$transactions = $support->getTransactions();
if ($transactions->isEmpty()) {
return EmbeddableMoney::of(new Money(0, self::CURRENCY));
}
$first = $transactions->first();
$money = new Money(0, $first->getMoney()->getCurrency());
foreach ($transactions as $transaction) {
$money = $this->moneyService->add($transaction->getMoney(), $money);
}
return EmbeddableMoney::of($money);
}
private function getInvestMsg(array $record, array $context)
{
$query = $this->getDbConnection($context)->prepare(
'SELECT * FROM `invest_msg` m WHERE m.invest = :invest'
);
$query->execute(['invest' => $record['id']]);
return $query->fetch(\PDO::FETCH_ASSOC);
}
private function getChargeType(array $record): ChargeType
{
if (\in_array($record['method'], ['stripe_subscription'])) {
return ChargeType::Recurring;
}
return ChargeType::Single;
}
private function getChargeMoney(int $amount, string $currency): EmbeddableMoney
{
$amount = $amount * 100;
if ($amount >= self::MAX_INT) {
$amount = self::MAX_INT;
}
return new EmbeddableMoney($amount, $currency);
}
private function getChargeStatus(array $record): ChargeStatus
{
switch ($record['status']) {
case 0:
case 1:
case 3:
case 7:
if ($record['issue'] === 1) {
return ChargeStatus::InPending;
}
return ChargeStatus::Charged;
case 2:
case 4:
case 6:
return ChargeStatus::Refunded;
default:
return ChargeStatus::InPending;
}
}
private function getCheckoutStatus(array $record): CheckoutStatus
{
switch ($record['status']) {
case 0:
case 1:
case 3:
case 7:
if ($record['issue'] === 1) {
return CheckoutStatus::InPending;
}
return CheckoutStatus::Charged;
default:
return CheckoutStatus::InPending;
}
}
private function getCheckoutGateway(array $record): string
{
switch ($record['method']) {
case 'stripe_subscription':
return StripeGateway::getName();
case 'pool':
return WalletGateway::getName();
case 'paypal':
return PaypalGateway::getName();
case 'tpv':
return CecaGateway::getName();
case 'cash':
return CashGateway::getName();
case 'drop':
return DropGateway::getName();
default:
return '';
}
}
/**
* @return Tracking[]
*/
private function getCheckoutTrackings(array $record): array
{
$v3Tracking = new Tracking();
$v3Tracking->setTitle(self::TRACKING_TITLE_V3);
$v3Tracking->setValue($record['id']);
$trackings = [$v3Tracking];
if (!empty($record['payment'])) {
$payment = new Tracking();
$payment->setTitle(self::TRACKING_TITLE_PAYMENT);
$payment->setValue($record['payment']);
$trackings[] = $payment;
}
if (!empty($record['transaction'])) {
$transaction = new Tracking();
$transaction->setTitle(self::TRACKING_TITLE_TRANSACTION);
$transaction->setValue($record['transaction']);
$trackings[] = $transaction;
}
if (!empty($record['preapproval'])) {
$preapproval = new Tracking();
$preapproval->setTitle(self::TRACKING_TITLE_PREAPPROVAL);
$preapproval->setValue($record['preapproval']);
$trackings[] = $preapproval;
}
return $trackings;
}
private function getInvestReward(array $record, array $context)
{
$query = $this->getDbConnection($context)->prepare(
'SELECT * FROM `invest_reward` ir WHERE ir.invest = :invest'
);
$query->execute(['invest' => $record['id']]);
return $query->fetch(\PDO::FETCH_ASSOC);
}
private function getReward(array $record, array $context): ?Reward
{
$invested = $this->getInvestReward($record, $context);
if (!$invested) {
return null;
}
$id = $invested['reward'];
if (isset($this->rewardCache[$id])) {
return $this->rewardRepository->find($this->rewardCache[$id]);
}
$reward = $this->rewardRepository->findOneBy(['migratedId' => $invested['reward']]);
$this->rewardCache[$id] = $reward->getId();
return $reward;
}
}
@@ -2,7 +2,7 @@
namespace App\Benzina;
trait CheckoutsPumpTrait
trait InvestsPumpTrait
{
private const INVEST_KEYS = [
'id',
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Benzina;
use App\Entity\Interface\LocalizedEntityInterface;
use Gedmo\Translatable\Entity\Translation;
use Goteo\Benzina\Pump\ContextAwareTrait;
use Goteo\Benzina\Pump\DoctrinePumpTrait;
trait LocalizedPumpTrait
{
use ContextAwareTrait;
use DoctrinePumpTrait;
/** @var array<class-string, string[]> */
private static array $translatableFieldsCache = [];
public function localize(
LocalizedEntityInterface $entity,
array $localizations = [],
array $context = [],
array $fieldCallbacks = [],
): void {
if ($this->isDryRun($context)) {
return;
}
$em = $this->getEntityManager();
$meta = $em->getClassMetadata($entity::class);
$translations = $em->getRepository(Translation::class);
$translatableFields = $this->getTranslatableFields($meta);
foreach ($localizations as $localization) {
$locale = $localization['lang'] ?? null;
if (!$locale) {
continue;
}
$entity->addLocale($locale);
foreach ($translatableFields as $field) {
if (isset($fieldCallbacks[$field]) && is_callable($fieldCallbacks[$field])) {
$value = $fieldCallbacks[$field]($localization);
} else {
$value = $meta->getFieldValue($entity, $field);
}
if ($value !== null) {
$translations->translate($entity, $field, $locale, $value);
}
}
}
if ($this->preventFlushAndClear) {
return;
}
$em->flush();
$em->clear();
}
/**
* Get all #[Gedmo\Translatable] fields for this entity.
*
* @return string[]
*/
private function getTranslatableFields($meta): array
{
$className = $meta->getName();
if (isset(self::$translatableFieldsCache[$className])) {
return self::$translatableFieldsCache[$className];
}
$reflection = $meta->getReflectionClass();
$fields = [];
foreach ($reflection->getProperties() as $property) {
$attrs = $property->getAttributes(\Gedmo\Mapping\Annotation\Translatable::class);
if (!empty($attrs)) {
$fields[] = $property->getName();
}
}
self::$translatableFieldsCache[$className] = $fields;
return $fields;
}
}
+40 -4
View File
@@ -2,7 +2,7 @@
namespace App\Benzina;
use App\Entity\Money;
use App\Entity\EmbeddableMoney;
use App\Entity\Project\BudgetItem;
use App\Entity\Project\BudgetItemType;
use App\Entity\Project\Project;
@@ -15,7 +15,11 @@ use Goteo\Benzina\Pump\PumpInterface;
class ProjectsBudgetPump implements PumpInterface
{
use ArrayPumpTrait;
use DatabasePumpTrait;
use DoctrinePumpTrait;
use LocalizedPumpTrait;
private const MAX_INT = 2147483647;
private const COST_KEYS = [
'id',
@@ -49,24 +53,34 @@ class ProjectsBudgetPump implements PumpInterface
return;
}
$project = $this->getBudgetProject($record);
$project = $this->getProject($record);
if ($project === null) {
return;
}
$budgetItem = new BudgetItem();
$budgetItem->setMigrated(true);
$budgetItem->setMigratedId($record['id']);
$budgetItem->setTranslatableLocale($project->getLocales()[0]);
$budgetItem->setProject($project);
$budgetItem->setType($this->getCostType($record));
$budgetItem->setTitle($record['cost']);
$budgetItem->setDescription($record['description'] ?? $record['cost']);
$budgetItem->setMoney(new Money($record['amount'] * 100, 'EUR'));
$budgetItem->setMoney($this->getCostMoney($record['amount'], 'EUR'));
$budgetItem->setDeadline($this->getDeadline($record));
$this->setPreventFlushAndClear(true);
$this->persist($budgetItem, $context);
$localizations = $this->getCostLocalizations($budgetItem, $context);
$this->setPreventFlushAndClear(false);
$this->localize($budgetItem, $localizations, $context, [
'title' => fn($l) => $l['cost'],
]);
}
private function getBudgetProject(array $record): ?Project
private function getProject(array $record): ?Project
{
return $this->projectRepository->findOneBy(['migratedId' => $record['project']]);
}
@@ -91,4 +105,26 @@ class ProjectsBudgetPump implements PumpInterface
return ProjectDeadline::Optimum;
}
private function getCostLocalizations(BudgetItem $budgetItem, array $context): array
{
$query = $this->getDbConnection($context)->prepare(
'SELECT * FROM `cost_lang` l WHERE l.id = :cost'
);
$query->execute(['cost' => $budgetItem->getMigratedId()]);
return $query->fetchAll();
}
private function getCostMoney(int $amount, string $currency): EmbeddableMoney
{
$amount = $amount * 100;
if ($amount >= self::MAX_INT) {
$amount = self::MAX_INT;
}
return new EmbeddableMoney($amount, $currency);
}
}
+113 -28
View File
@@ -3,17 +3,19 @@
namespace App\Benzina;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectCalendar;
use App\Entity\Project\ProjectCategory;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\ProjectTerritory;
use App\Entity\Project\ProjectVideo;
use App\Entity\Project\Update;
use App\Entity\Territory;
use App\Entity\User\User;
use App\Repository\Project\ProjectRepository;
use App\Repository\User\UserRepository;
use App\Service\Embed\EmbedService;
use App\Service\Project\CalendarService;
use App\Service\Project\TerritoryService;
use Doctrine\Common\Collections\ArrayCollection;
use Goteo\Benzina\Pump\ArrayPumpTrait;
use Goteo\Benzina\Pump\DoctrinePumpTrait;
use Goteo\Benzina\Pump\PumpInterface;
@@ -24,12 +26,13 @@ class ProjectsPump implements PumpInterface
use DoctrinePumpTrait;
use DatabasePumpTrait;
use ProjectsPumpTrait;
use LocalizedPumpTrait;
public function __construct(
private ProjectRepository $projectRepository,
private UserRepository $userRepository,
private TerritoryService $territoryService,
private EmbedService $embedService,
private CalendarService $calendarService,
) {}
public function supports(mixed $sample): bool
@@ -48,7 +51,12 @@ class ProjectsPump implements PumpInterface
}
$status = $this->getProjectStatus($record);
if (\in_array($status, [ProjectStatus::InEditing, ProjectStatus::Rejected])) {
if (\in_array($status, [ProjectStatus::Rejected])) {
return;
}
$created = new \DateTime($record['created']);
if (\in_array($status, [ProjectStatus::InDraft, ProjectStatus::InEditing]) && $created < new \DateTime('2024-01-01')) {
return;
}
@@ -57,38 +65,49 @@ class ProjectsPump implements PumpInterface
return;
}
$project = new Project();
$project->setTranslatableLocale($record['lang']);
$project->setTitle($record['name']);
$project = $this->getProject($record);
if ($project === null) {
$project = new Project();
}
$project->setSlug($record['id']);
$project->setSubtitle($record['subtitle']);
$project->setCategory($this->getProjectCategory($record));
$project->setTerritory($this->getProjectTerritory($record));
$project->setDescription($record['description']);
$project->setVideo($this->getProjectVideo($record));
$project->setOwner($owner);
$project->setStatus($status);
$project->setMigrated(true);
$project->setMigratedId($record['id']);
$project->setDateCreated(new \DateTime($record['created']));
$project->setDateCreated($created);
$project->setDateUpdated(new \DateTime());
$updates = $this->getProjectUpdates($project, $context);
foreach ($updates as $update) {
$project->addUpdate($update);
}
$project->setTranslatableLocale($record['lang']);
$project->setUpdates(new ArrayCollection($this->getProjectUpdates($project, $context)));
$conf = $this->getProjectConf($project, $context);
$project->setDeadline($this->getProjectDeadline($conf));
$project->setCalendar($this->calendarService->makeCalendar(
$project->getDeadline(),
new \DateTimeImmutable($record['published']),
$conf['days_round1'],
$conf['days_round2'],
));
$project->setCalendar($this->getProjectCalendar($record));
$project->addLocale($record['lang']);
$project->setTitle($record['name'] ?? '');
$project->setSubtitle($record['subtitle'] ?? '');
$project->setDescription($this->getProjectDescription($record));
$this->setPreventFlushAndClear(true);
$this->persist($project, $context);
$localizations = $this->getProjectLocalizations($project, $context);
$this->setPreventFlushAndClear(false);
$this->localize($project, $localizations, $context, [
'title' => fn($l) => $l['name'],
'description' => fn($l) => $this->getProjectDescription($l),
]);
}
private function getProject(array $record): ?Project
{
return $this->projectRepository->findOneBy(['migratedId' => $record['id']]);
}
private function getProjectOwner(array $record): ?User
@@ -96,6 +115,55 @@ class ProjectsPump implements PumpInterface
return $this->userRepository->findOneBy(['migratedId' => $record['owner']]);
}
private function getProjectDescription(array $record): string
{
$lang = $record['lang'];
$hasTitles = \array_key_exists($lang, self::PROJECT_DESC_TITLES);
$description = $record['description'];
if ($hasTitles) {
$description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['about']);
}
$description .= \sprintf("\n%s", $record['about']);
if ($hasTitles) {
$description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['motivation']);
}
$description .= \sprintf("\n\n%s", $record['motivation']);
if ($hasTitles) {
$description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['related']);
}
$description .= \sprintf("\n%s", $record['related']);
return $description;
}
private function getProjectLocalizations(Project $project, array $context): array
{
$query = $this->getDbConnection($context)->prepare(
'SELECT * FROM `project_lang` l WHERE l.id = :project'
);
$query->execute(['project' => $project->getMigratedId()]);
return $query->fetchAll();
}
private function getProjectCalendar(array $record): ProjectCalendar
{
$calendar = new ProjectCalendar();
$calendar->release = new \DateTimeImmutable($record['published']);
$calendar->minimum = new \DateTimeImmutable($record['passed'] ?? $record['closed']);
$calendar->optimum = new \DateTimeImmutable($record['success'] ?? $record['closed']);
return $calendar;
}
private function getProjectStatus(array $record): ProjectStatus
{
switch ($record['status']) {
@@ -148,24 +216,37 @@ class ProjectsPump implements PumpInterface
}
}
private function getProjectTerritory(array $record): ProjectTerritory
private function getProjectTerritory(array $record): Territory
{
if ($record['project_location'] === null) {
return Territory::unknown();
}
$cleanAddress = $this->cleanProjectLocation($record['project_location'], 2);
if ($cleanAddress === '') {
return ProjectTerritory::unknown();
return Territory::unknown();
}
return $this->territoryService->search($cleanAddress);
}
private function getProjectVideo(array $record): ?ProjectVideo
private function getProjectVideoSource(array $record): ?string
{
if ($record['video'] === null) {
return null;
if ($record['video'] !== null) {
return \trim($record['video']);
}
$url = \trim($record['video']);
if ($record['media'] !== null) {
return \trim($record['media']);
}
return null;
}
private function getProjectVideo(array $record): ?ProjectVideo
{
$url = $this->getProjectVideoSource($record);
if ($url === '') {
return null;
@@ -206,12 +287,16 @@ class ProjectsPump implements PumpInterface
$posts = $this->getProjectBlogPosts($project, $context);
foreach ($posts as $post) {
if ($post['publish'] == 0) {
continue;
}
$update = new Update();
$update->setProject($project);
$update->setTranslatableLocale($project->getLocales()[0]);
$update->setTitle($post['title']);
$update->setSubtitle($post['subtitle'] ?? '');
$update->setBody($post['text']);
$update->setBody($post['text'] ?? '');
$update->setDate(new \DateTime($post['date']));
$updates[] = $update;
+91 -20
View File
@@ -23,7 +23,7 @@ trait ProjectsPumpTrait
// Remove secondary conjoined places from locations
// e.g: "España y el mundo" -> "España"
foreach ([' / ', ' | ', ' - ', ' y ', ' and ', ' & '] as $conjoinment) {
foreach ([' / ', ' | ', ' - ', ' y ', ' i ', ' and ', ' & '] as $conjoinment) {
if (\str_contains($location, $conjoinment)) {
$location = \explode($conjoinment, $location)[0];
}
@@ -41,18 +41,6 @@ trait ProjectsPumpTrait
// Process comma-separated address pieces
$location = \explode(',', $location);
$location = \array_map(function ($l) {
$l = trim($l);
// Normalize typos and name variations
foreach (self::COMMON_VARIATIONS as $standard => $variations) {
if (in_array(\mb_strtoupper($l), $variations)) {
$l = $standard;
}
}
return $l;
}, $location);
// Clean non desired location pieces
$location = \array_filter($location, function ($l) {
@@ -68,11 +56,26 @@ trait ProjectsPumpTrait
return true;
});
// Normalize location pieces
$location = \array_map(function ($l) {
// Trim spaces and numbers at start of piece
$l = \preg_replace('/^[\d ]+/', '', \trim($l));
// Normalize typos and name variations
foreach (self::COMMON_VARIATIONS as $standard => $variations) {
if (in_array(\mb_strtoupper($l), $variations)) {
$l = $standard;
}
}
return $l;
}, $location);
$location = \join(', ', \array_slice($location, -1 * $detailLevel));
// Trim remaining numbers and punctuation marks
$location = \preg_replace('/^[\d\.\-;]+/', '', $location);
$location = \preg_replace('/[\d\.\-;]+$/', '', $location);
$location = \preg_replace('/^[\d\.\,\-;]+/', '', $location);
$location = \preg_replace('/[\d\.\,\-;]+$/', '', $location);
return \mb_strtoupper(\trim($location));
}
@@ -81,22 +84,90 @@ trait ProjectsPumpTrait
* @var array<string, array> The standard preferred name and a list of possible variations and misspellings
*/
private const COMMON_VARIATIONS = [
'ESPAÑA' => ['ESPANYA', 'ESPANHA', 'ESPAGNE', 'SPAGNA', 'SPANIEN', 'SPAIN'],
'CATALUÑA' => ['CATALUNYA', 'PAÏSOS CATALANS'],
'ANDALUCÍA' => ['ANDALUCIA', 'ANDALUSIA'],
'PERÚ' => ['PÉROU'],
'ECUADOR' => ['EQUADOR'],
'MÉXICO' => ['MESSICO', 'MX'],
'CIUDAD DE MÉXICO' => ['CMDX'],
'EUROPA' => ['EUROPEAN UNION'],
'FRANCIA' => ['FRANCE'],
'ITALIA' => ['ITALY'],
'ALEMANIA' => ['GERMANY'],
'FINLANDIA' => ['FINLAND'],
'SUECIA' => ['SWEDEN', 'SVERIGE'],
'ESPAÑA' => ['ESPANYA', 'ESPANHA', 'ESPAGNE', 'SPAGNA', 'SPANIEN', 'SPAIN', 'ESTADO ESPAÑOL', 'ESPAINIA'],
'ANDALUCÍA' => ['ANDALUCIA', 'ANDALUSIA'],
'CÁDIZ' => ['CADIZ'],
'CÓRDOBA' => ['CORDOBA'],
'COMUNIDAD VALENCIANA' => ['COMUNITAT VALENCIANA', 'PAÍS VALENCIÀ'],
'VALENCIA' => ['VALÈNCIA', 'PROVINCIA DE VALENCIA'],
'ALICANTE' => ['ALACANT'],
'CASTELLÓN DE LA PLANA' => ['CASTELLÓ DE LA PLANA'],
'LAS PALMAS DE GRAN CANARIA' => ['LAS PALMAS'],
'CÁCERES' => ['CACERES'],
'MÉRIDA' => ['MERIDA'],
'LEÓN' => ['LEON'],
'LEGANÉS' => ['LEGANES'],
'BALEARES' => ['BALEAREN', 'ISLAS BALEARES', 'ILLES BALEARS', 'BALEARIC ISLANDS'],
'GALICIA' => ['GALIZA'],
'BILBAO' => ['BILBO'],
'A CORUÑA' => ['LA CORUÑA'],
'OURENSE' => ['ORENSE'],
'CATALUÑA' => ['CATALUNYA', 'PAÏSOS CATALANS'],
'LLEIDA' => ['LÉRIDA'],
'BARCELONA' => ['PROVINCIA DE BARCELONA'],
'TARRASA' => ['TERRASA'],
'BARCELONA' => ['PROVINCIA DE BARCELONA', 'BARCELONE', 'A BARCELONA'],
'HOSPITALET DE LLOBREGAT' => ["L'HOSPITALET DE LLOBREGAT"],
'SANT ADRIÀ DE BESÒS' => ['SANT ADRIÀ DEL BESÒS'],
'MURCIA' => ['REGIÓN DE MURCIA'],
'PAÍS VASCO' => ['EUSKADI', 'EUSKAL HERRIA'],
'BILBAO' => ['BILBO'],
'SAN SEBASTIÁN' => ['DONOSTIA', 'DONOSTIA-SAN SEBASTIÁN'],
'VIZCAYA' => ['BIZKAIA'],
'IRÚN' => ['IRUN'],
'NAVARRA' => ['NAVARRE', 'NAFARROA'],
'PAMLONA' => ['IRUÑEA'],
];
private const PROJECT_DESC_TITLES = [
'es' => [
'about' => 'Descripción del proyecto. Características, fortalezas y diferenciales.',
'motivation' => 'Motivación y a quién va dirigido el proyecto.',
'related' => 'Experiencia previa y equipo.',
],
'gl' => [
'about' => 'Características básicas.',
'motivation' => 'Motivación e a quen vai dirixido o proxecto.',
'related' => 'Experiencia previa e equipo.',
],
'eu' => [
'about' => 'Oinarrizko ezaugarriak eta helburuak.',
'motivation' => 'Zerk bultzatzen zaitu eta zeini zuzenduta dago proiektua.',
'related' => 'Proiektuaren deskribapenean derrigorrekoa da azaltzea kontutan hartzen den taldearen esperientzia.',
],
'ca' => [
'about' => 'Característiques bàsiques i objectius.',
'motivation' => 'Motivació i a qui va dirigit el projecte.',
'related' => 'Experiència prèvia i equip.',
],
'en' => [
'about' => 'Main features and goals of the crowdfunding campaign.',
'motivation' => 'Why this is important.',
'related' => 'Team and experience.',
],
'fr' => [
'about' => 'Caractéristiques principales.',
'motivation' => 'Pourquoi est-ce important.',
'related' => 'Expérience antérieure et équipe.',
],
'it' => [
'about' => 'Caratteristiche fondamentali.',
'motivation' => 'Obiettivo e a chi si dirige il progetto.',
'related' => 'Esperienza e team.',
],
'de' => [
'about' => 'Hauptmerkmale.',
'motivation' => 'Warum das wichtig ist.',
'related' => 'Team und Erfahrung.',
],
];
private const PROJECT_KEYS = [
+29 -6
View File
@@ -2,7 +2,7 @@
namespace App\Benzina;
use App\Entity\Money;
use App\Entity\EmbeddableMoney;
use App\Entity\Project\BudgetItemType;
use App\Entity\Project\Project;
use App\Entity\Project\Reward;
@@ -14,7 +14,9 @@ use Goteo\Benzina\Pump\PumpInterface;
class ProjectsRewardsPump implements PumpInterface
{
use ArrayPumpTrait;
use DatabasePumpTrait;
use DoctrinePumpTrait;
use LocalizedPumpTrait;
private const REWARD_KEYS = [
'id',
@@ -59,24 +61,34 @@ class ProjectsRewardsPump implements PumpInterface
return;
}
$project = $this->getBudgetProject($record);
$project = $this->getProject($record);
if ($project === null) {
return;
}
$reward = new Reward();
$reward->addLocale($project->getLocales()[0]);
$reward->setProject($project);
$reward->setMigrated(true);
$reward->setMigratedId($record['id']);
$reward->setTitle($record['reward']);
$reward->setDescription($record['description'] ?? $record['reward']);
$reward->setMoney(new Money($record['amount'] * 100, 'EUR'));
$reward->setHasUnits($record['units'] > 0);
$reward->setMoney(new EmbeddableMoney($record['amount'] * 100, 'EUR'));
$reward->setIsFinite($record['units'] > 0);
$reward->setUnitsTotal($record['units'] ?? 0);
$reward->setUnitsAvailable($record['units'] ?? 0);
$this->setPreventFlushAndClear(true);
$this->persist($reward, $context);
$localizations = $this->getRewardLocalizations($reward, $context);
$this->setPreventFlushAndClear(false);
$this->localize($reward, $localizations, $context, [
'title' => fn($l) => $l['reward'],
]);
}
private function getBudgetProject(array $record): ?Project
private function getProject(array $record): ?Project
{
return $this->projectRepository->findOneBy(['migratedId' => $record['project']]);
}
@@ -92,4 +104,15 @@ class ProjectsRewardsPump implements PumpInterface
return BudgetItemType::Infrastructure;
}
}
private function getRewardLocalizations(Reward $reward, array $context): array
{
$query = $this->getDbConnection($context)->prepare(
'SELECT * FROM `reward_lang` l WHERE l.id = :reward'
);
$query->execute(['reward' => $reward->getMigratedId()]);
return $query->fetchAll();
}
}
+3 -5
View File
@@ -59,20 +59,18 @@ class UsersPump implements PumpInterface
$handle = UserService::asHandle($record['email']);
}
$sequenceNumber = \substr($this->userCount, -2, 2);
return \sprintf('%s_%s', $handle, $sequenceNumber);
return \sprintf('%s_%02d', $handle, $this->userCount % 100);
}
private function getDateCreated(array $record): \DateTime
{
$created = new \DateTime($record['created']);
$created = new \DateTime($record['created'] ?? '0000-00-00');
if ($created > new \DateTime('2011-01-01')) {
return $created;
}
return new \DateTime($record['modified']);
return new \DateTime($record['modified'] ?? 'now');
}
private function getUserType(array $record): UserType
+6 -18
View File
@@ -2,33 +2,21 @@
namespace App\Dto\Gateway;
use ApiPlatform\Metadata as API;
use App\Gateway\ChargeStatus;
use App\Gateway\ChargeType;
use Symfony\Component\Validator\Constraints as Assert;
class ChargeUpdationDto
{
/**
* The unique identifier for the charge.
* The unique identifier for the GatewayCharge.
*/
#[API\ApiProperty(writable: false)]
public int $id;
/**
* The type of the charge.
*/
public ChargeType $type;
/**
* The title or name of the charge.
*/
public string $title;
/**
* A detailed description of the charge.
*/
public string $description;
/**
* The current payment status of the charge.
* To ask for a refund, set the status `to_refund`.
*/
#[Assert\Choice([ChargeStatus::ToRefund])]
public ChargeStatus $status;
}
@@ -1,6 +1,6 @@
<?php
namespace App\Dto;
namespace App\Dto\Gateway;
use ApiPlatform\Metadata as API;
use App\Entity\Gateway\Checkout;
@@ -0,0 +1,28 @@
<?php
namespace App\Dto\Matchfunding;
use App\ApiResource\Matchfunding\MatchCallApiResource;
use App\ApiResource\Project\ProjectApiResource;
use App\Validator\SingleProjectSubmissionPerCall;
use Symfony\Component\Validator\Constraints as Assert;
#[SingleProjectSubmissionPerCall()]
class MatchCallSubmissionCreationDto
{
/**
* The MatchCall to which this MatchCallSubmission belongs to.
*/
#[Assert\NotBlank()]
#[Assert\Expression(
expression: 'value.status === enum("App\\\Entity\\\Matchfunding\\\MatchCallStatus::InCalling")',
message: 'MatchCallSubmissions can only relate to a MatchCall with `in_calling` status.'
)]
public MatchCallApiResource $call;
/**
* The Project that applied for the MatchCall.
*/
#[Assert\NotBlank()]
public ProjectApiResource $project;
}
+2 -2
View File
@@ -2,10 +2,10 @@
namespace App\Dto;
use App\ApiResource\Project\ProjectTerritoryApiResource;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectCategory;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Territory;
use App\Mapping\Transformer\ProjectVideoMapTransformer;
use AutoMapper\Attribute\MapTo;
use Symfony\Component\Validator\Constraints as Assert;
@@ -36,7 +36,7 @@ class ProjectCreationDto
*/
#[Assert\NotBlank()]
#[Assert\Valid()]
public ProjectTerritoryApiResource $territory;
public Territory $territory;
/**
* Free-form rich text description for the Project.
+2 -2
View File
@@ -3,11 +3,11 @@
namespace App\Dto;
use ApiPlatform\Metadata as API;
use App\ApiResource\Project\ProjectTerritoryApiResource;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectCategory;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Territory;
use App\Mapping\Transformer\ProjectVideoMapTransformer;
use AutoMapper\Attribute\MapTo;
use Symfony\Component\Validator\Constraints as Assert;
@@ -36,7 +36,7 @@ class ProjectUpdationDto
* ISO 3166 data about the Project's territory of interest.
*/
#[Assert\Valid()]
public ProjectTerritoryApiResource $territory;
public Territory $territory;
/**
* Free-form rich text description for the Project.
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Dto;
use App\ApiResource\Gateway\ChargeApiResource;
use App\ApiResource\Project\RewardApiResource;
use App\Validator\AvailableRewardUnits;
use App\Validator\EnoughRewardCharge;
use App\Validator\SameProjectRewardCharge;
use App\Validator\SingleRewardClaimPerCharge;
use Symfony\Component\Validator\Constraints as Assert;
#[EnoughRewardCharge()]
#[SameProjectRewardCharge()]
#[SingleRewardClaimPerCharge()]
class RewardClaimCreationDto
{
/**
* The GatewayCharge granting access to the ProjectReward.
*/
#[Assert\NotBlank()]
public ChargeApiResource $charge;
/**
* The ProjectReward being claimed.
*/
#[Assert\NotBlank()]
#[AvailableRewardUnits()]
public RewardApiResource $reward;
}
+25 -9
View File
@@ -3,6 +3,7 @@
namespace App\Entity\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Matchfunding\MatchCall;
use App\Entity\Project\Project;
use App\Entity\Tipjar;
use App\Entity\Trait\UserOwnedTrait;
@@ -49,6 +50,9 @@ class Accounting
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist'])]
private ?Tipjar $tipjar = null;
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist'])]
private ?MatchCall $matchCall = null;
/**
* Create a new Accounting entity instance for the given owner.
*/
@@ -60,15 +64,6 @@ class Accounting
return $accounting;
}
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;
@@ -107,6 +102,8 @@ class Accounting
return $this->getProject();
case Tipjar::class:
return $this->getTipjar();
case MatchCall::class:
return $this->getMatchCall();
}
return null;
@@ -127,6 +124,8 @@ class Accounting
return $this->setProject($owner);
case Tipjar::class:
return $this->setTipjar($owner);
case MatchCall::class:
return $this->setMatchCall($owner);
}
return $this;
@@ -197,4 +196,21 @@ class Accounting
return $this;
}
public function getMatchCall(): ?MatchCall
{
return $this->matchCall;
}
public function setMatchCall(MatchCall $matchCall): static
{
// set the owning side of the relation if necessary
if ($matchCall->getAccounting() !== $this) {
$matchCall->setAccounting($this);
}
$this->matchCall = $matchCall;
return $this;
}
}
+3 -1
View File
@@ -2,7 +2,7 @@
namespace App\Entity\Accounting;
use App\Entity\Money;
use App\Entity\EmbeddableMoney as Money;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Accounting\TransactionRepository;
@@ -21,6 +21,8 @@ use Symfony\Component\Validator\Constraints as Assert;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Table(name: 'accounting_transaction')]
#[ORM\Entity(repositoryClass: TransactionRepository::class)]
#[ORM\Index(fields: ['origin'])]
#[ORM\Index(fields: ['target'])]
class Transaction
{
use TimestampedCreationEntity;
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Entity;
use App\Money\Conversion\Conversion;
use App\Money\MoneyInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* NOT FINANCIALLY SAFE.\
* Use `Brick\Money` to perform monetary calculations.
*
* @see \Brick\Money
*/
#[ORM\Embeddable]
class EmbeddableMoney implements MoneyInterface
{
/**
* An amount of currency.\
* Expressed as the minor unit, e.g: cents, pennies, etc.
*/
#[ORM\Column(type: 'integer', nullable: true)]
public readonly ?int $amount;
/**
* 3-letter ISO 4217 currency code.
*/
#[ORM\Column(type: 'string', nullable: true)]
public readonly ?string $currency;
#[ORM\Column(type: 'json', nullable: true)]
private readonly ?array $conversion;
public function __construct(
?int $amount = null,
?string $currency = null,
?Conversion $conversion = null,
) {
$this->amount = $amount;
$this->currency = $currency;
$this->conversion = $conversion?->toArray();
}
public static function of(MoneyInterface $moneyInterface): self
{
return new self(
$moneyInterface->getAmount(),
$moneyInterface->getCurrency(),
$moneyInterface->getConversion()
);
}
public function getAmount(): int
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
public function getConversion(): ?Conversion
{
return $this->conversion
? Conversion::fromArray($this->conversion)
: null;
}
}
+6 -4
View File
@@ -4,7 +4,8 @@ namespace App\Entity\Gateway;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\Money;
use App\Entity\EmbeddableMoney as Money;
use App\Entity\Trait\MigratedEntity;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Entity\Trait\TimestampedUpdationEntity;
use App\Gateway\ChargeStatus;
@@ -25,9 +26,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[MapProvider(EntityMapProvider::class)]
#[Gedmo\Loggable()]
#[ORM\Table(name: 'checkout_charge')]
#[ORM\Index(fields: ['migratedId'])]
#[ORM\Entity(repositoryClass: ChargeRepository::class)]
class Charge
{
use MigratedEntity;
use TimestampedCreationEntity;
use TimestampedUpdationEntity;
@@ -77,7 +80,7 @@ class Charge
/**
* @var Collection<int, Transaction>
*/
#[ORM\JoinTable(name: 'checkout_charge_transaction')]
#[ORM\JoinTable(name: 'checkout_charge_trxs')]
#[ORM\ManyToMany(targetEntity: Transaction::class, cascade: ['persist'])]
private Collection $transactions;
@@ -86,12 +89,11 @@ class Charge
*/
#[Gedmo\Versioned]
#[ORM\Column()]
private ?ChargeStatus $status = null;
private ?ChargeStatus $status = ChargeStatus::InPending;
public function __construct()
{
$this->transactions = new ArrayCollection();
$this->status = ChargeStatus::InPending;
}
public function getId(): ?int
+16 -31
View File
@@ -9,7 +9,6 @@ use App\Entity\Trait\TimestampedUpdationEntity;
use App\Gateway\CheckoutStatus;
use App\Gateway\Link;
use App\Gateway\RefundStrategy;
use App\Gateway\Tracking;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Gateway\CheckoutRepository;
use App\Validator\SupportedChargeTypes;
@@ -77,8 +76,6 @@ class Checkout
/**
* The address to where the user must be redirected to.
*/
#[Assert\NotBlank()]
#[Assert\Url()]
#[ORM\Column(type: Types::TEXT)]
private ?string $returnUrl = null;
@@ -99,18 +96,16 @@ class Checkout
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[]
* @var Collection<int, Tracking>
*/
#[ORM\Column]
private array $trackings = [];
#[ORM\OneToMany(targetEntity: Tracking::class, mappedBy: 'checkout', cascade: ['persist'])]
private Collection $trackings;
public function __construct()
{
$this->status = CheckoutStatus::InPending;
$this->charges = new ArrayCollection();
$this->trackings = new ArrayCollection();
}
public function getId(): ?int
@@ -268,41 +263,31 @@ class Checkout
}
/**
* @return Tracking[]
* @return Collection<int, Tracking>
*/
public function getTrackings(): array
public function getTrackings(): Collection
{
return array_map(fn($t) => Tracking::tryFrom($t), $this->trackings);
}
/**
* @param Tracking[] $trackings
*/
public function setTrackings(array $trackings): static
{
$this->trackings = $trackings;
return $this;
return $this->trackings;
}
public function addTracking(Tracking $tracking): static
{
$this->removeTracking($tracking);
$this->trackings = [...$this->trackings, $tracking];
if (!$this->trackings->contains($tracking)) {
$this->trackings->add($tracking);
$tracking->setCheckout($this);
}
return $this;
}
public function removeTracking(Tracking $tracking): static
{
$this->trackings = \array_filter(
\array_map(fn($t) => Tracking::tryFrom($t), $this->trackings),
function (Tracking $existingTracking) use ($tracking) {
return $existingTracking->title !== $tracking->title
&& $existingTracking->value !== $tracking->value;
if ($this->trackings->removeElement($tracking)) {
// set the owning side to null (unless already changed)
if ($tracking->getCheckout() === $this) {
$tracking->setCheckout(null);
}
);
}
return $this;
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Entity\Gateway;
use App\Mapping\Provider\TrackingMapProvider;
use App\Repository\Gateway\TrackingRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(TrackingMapProvider::class)]
#[ORM\Table(name: 'checkout_tracking')]
#[ORM\Entity(repositoryClass: TrackingRepository::class)]
class Tracking
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'trackings')]
#[ORM\JoinColumn(nullable: false)]
private ?Checkout $checkout = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(length: 255)]
private ?string $value = null;
public function __construct(
?string $title = null,
?string $value = null,
) {
$this->title = $title ?? $title;
$this->value = $value ?? $value;
}
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 getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getValue(): ?string
{
return $this->value;
}
public function setValue(string $value): static
{
$this->value = $value;
return $this;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Entity\Matchfunding;
/**
* Defines against what amount of money a Matchfunding shall be.
*/
enum MatchAgainst: string
{
public const DEFAULT = self::Charge;
/**
* The money to be matched will be extracted from a Charge item.
*/
case Charge = 'charge';
/**
* The money to be matched will be from the minimum amount in a Project's budget.
*/
case BudgetMin = 'budget_min';
/**
* The money to be matched will be from the optimum amount in a Project's budget.
*/
case BudgetOpt = 'budget_opt';
}
+238
View File
@@ -0,0 +1,238 @@
<?php
namespace App\Entity\Matchfunding;
use App\Entity\Accounting\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Territory;
use App\Entity\User\User;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Matchfunding\MatchCallRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: MatchCallRepository::class)]
class MatchCall implements AccountingOwnerInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToOne(inversedBy: 'matchCall', cascade: ['persist'])]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $accounting = null;
/**
* @var Collection<int, MatchStrategy>
*/
#[ORM\OneToMany(targetEntity: MatchStrategy::class, mappedBy: 'call', cascade: ['persist'])]
private Collection $strategies;
/**
* @var Collection<int, MatchCallSubmission>
*/
#[ORM\OneToMany(mappedBy: 'call', targetEntity: MatchCallSubmission::class)]
private Collection $submissions;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
private Collection $managers;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Embedded(class: Territory::class)]
private ?Territory $territory = null;
#[ORM\Column(enumType: MatchCallStatus::class)]
private ?MatchCallStatus $status = MatchCallStatus::InEditing;
public function __construct()
{
$this->accounting = Accounting::of($this);
$this->managers = new ArrayCollection();
$this->submissions = new ArrayCollection();
$this->strategies = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getAccounting(): ?Accounting
{
return $this->accounting;
}
public function setAccounting(Accounting $accounting): static
{
$this->accounting = $accounting;
return $this;
}
/**
* @return Collection<int, MatchStrategy>
*/
public function getStrategies(): Collection
{
return $this->strategies;
}
/**
* @param Collection<int, MatchStrategy> $strategies
*/
public function setStrategies(Collection $strategies): static
{
$this->strategies = $strategies;
return $this;
}
public function addStrategy(MatchStrategy $strategy): static
{
if (!$this->strategies->contains($strategy)) {
$this->strategies = $this->strategies->map(function (MatchStrategy $s) use ($strategy) {
if ($s->getRanking() >= $strategy->getRanking()) {
$s->setRanking($s->getRanking() + 1);
}
return $s;
});
$this->strategies->add($strategy);
$strategy->setCall($this);
}
return $this;
}
public function removeStrategy(MatchStrategy $strategy): static
{
if ($this->strategies->removeElement($strategy)) {
$this->strategies = $this->strategies->map(function (MatchStrategy $s) use ($strategy) {
if ($s->getRanking() > $strategy->getRanking()) {
$s->setRanking($s->getRanking() - 1);
}
return $s;
});
}
return $this;
}
/**
* @return Collection<int, MatchCallSubmission>
*/
public function getSubmissions(): Collection
{
return $this->submissions;
}
public function addSubmission(MatchCallSubmission $submission): static
{
if (!$this->submissions->contains($submission)) {
$this->submissions->add($submission);
$submission->setCall($this);
}
return $this;
}
public function removeSubmission(MatchCallSubmission $submission): static
{
if ($this->submissions->removeElement($submission)) {
// set the owning side to null (unless already changed)
if ($submission->getCall() === $this) {
$submission->setCall(null);
}
}
return $this;
}
/**
* @return Collection<int, User>
*/
public function getManagers(): Collection
{
return $this->managers;
}
public function addManager(User $manager): static
{
if (!$this->managers->contains($manager)) {
$this->managers->add($manager);
}
return $this;
}
public function removeManager(User $manager): static
{
$this->managers->removeElement($manager);
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;
}
public function getTerritory(): ?Territory
{
return $this->territory;
}
public function setTerritory(Territory $territory): static
{
$this->territory = $territory;
return $this;
}
public function getStatus(): ?MatchCallStatus
{
return $this->status;
}
public function setStatus(MatchCallStatus $status): static
{
$this->status = $status;
return $this;
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Entity\Matchfunding;
enum MatchCallStatus: string
{
/**
* The MatchCall is still being configured by the managers.
*/
case InEditing = 'in_editing';
/**
* The MatchCall is taking in MatchCallSubmissions from Projects.
*/
case InCalling = 'in_calling';
/**
* The MatchCall is giving matchmade funding to the accepted Projects.
*/
case InMatchmaking = 'in_matchmaking';
/**
* The MatchCall finished giving funds and closing actions can now be performed.
*/
case ToClosed = 'to_closed';
/**
* The MatchCall is now fully finished and will remain as read-only.
*/
case Closed = 'closed';
}
@@ -0,0 +1,71 @@
<?php
namespace App\Entity\Matchfunding;
use App\Entity\Project\Project;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Matchfunding\MatchCallSubmissionRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: MatchCallSubmissionRepository::class)]
class MatchCallSubmission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'submissions')]
#[ORM\JoinColumn(nullable: false)]
private ?MatchCall $call = null;
#[ORM\ManyToOne(inversedBy: 'submissions')]
#[ORM\JoinColumn(nullable: false)]
private ?Project $project = null;
#[ORM\Column(enumType: MatchCallSubmissionStatus::class)]
private ?MatchCallSubmissionStatus $status = MatchCallSubmissionStatus::DEFAULT;
public function getId(): ?int
{
return $this->id;
}
public function getCall(): ?MatchCall
{
return $this->call;
}
public function setCall(?MatchCall $call): static
{
$this->call = $call;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
public function getStatus(): ?MatchCallSubmissionStatus
{
return $this->status;
}
public function setStatus(MatchCallSubmissionStatus $status): static
{
$this->status = $status;
return $this;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Entity\Matchfunding;
enum MatchCallSubmissionStatus: string
{
public const DEFAULT = self::ToReview;
/**
* The MatchCallSubmission is pending review.
*/
case ToReview = 'to_review';
/**
* The MatchCallSubmission is under review for the MatchCall.
*/
case InReview = 'in_review';
/**
* The MatchCallSubmission was accepted into the MatchCall.
*/
case Accepted = 'accepted';
/**
* The MatchCallSubmission was rejected out of the MatchCall.
*/
case Rejected = 'rejected';
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Entity\Matchfunding;
use App\Entity\EmbeddableMoney as Money;
use App\Mapping\Provider\EntityMapProvider;
use App\Matchfunding\Formula\MultiplicationFormula;
use App\Repository\Matchfunding\MatchStrategyRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: MatchStrategyRepository::class)]
class MatchStrategy
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'strategies')]
#[ORM\JoinColumn(nullable: false)]
private ?MatchCall $call = null;
#[ORM\Column]
private ?int $ranking = null;
#[ORM\Column(type: Types::ARRAY)]
private array $ruleNames = [];
#[ORM\Column(length: 255, nullable: true)]
private ?string $formulaName;
#[ORM\Embedded(class: Money::class)]
private ?object $limit = null;
#[ORM\Column(nullable: true)]
private ?float $factor = 1.0;
#[ORM\Column(enumType: MatchAgainst::class)]
private ?MatchAgainst $against = MatchAgainst::DEFAULT;
public static function of(
MatchCall $call,
?string $formulaName = null,
): MatchStrategy {
$strategy = new MatchStrategy();
$strategy->setCall($call);
$strategy->setFormulaName($formulaName ?? MultiplicationFormula::getName());
$strategy->setLimit(new Money(
2147483647,
$call->getAccounting()->getCurrency()
));
return $strategy;
}
public function getId(): ?int
{
return $this->id;
}
public function getCall(): ?MatchCall
{
return $this->call;
}
public function setCall(?MatchCall $call): static
{
$this->call = $call;
return $this;
}
public function getRanking(): ?int
{
return $this->ranking;
}
public function setRanking(int $ranking): static
{
$this->ranking = $ranking;
return $this;
}
public function getRuleNames(): array
{
return $this->ruleNames;
}
public function setRuleNames(array $ruleNames): static
{
$this->ruleNames = $ruleNames;
return $this;
}
public function getFormulaName(): ?string
{
return $this->formulaName;
}
public function setFormulaName(string $formulaName): static
{
$this->formulaName = $formulaName;
return $this;
}
public function getLimit(): ?Money
{
return $this->limit;
}
public function setLimit(Money $limit): static
{
$this->limit = $limit;
return $this;
}
public function getFactor(): ?float
{
return $this->factor;
}
public function setFactor(float $factor): static
{
$this->factor = $factor;
return $this;
}
public function getAgainst(): ?MatchAgainst
{
return $this->against;
}
public function setAgainst(MatchAgainst $against): static
{
$this->against = $against;
return $this;
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* NOT FINANCIALLY SAFE.\
* Use `Brick\Money` to perform monetary calculations.
*
* @see \Brick\Money
*/
#[ORM\Embeddable]
class Money
{
/**
* An amount of currency.\
* Expressed as the minor unit, e.g: cents, pennies, etc.
*/
#[ORM\Column(type: 'integer', nullable: true)]
public readonly ?int $amount;
/**
* 3-letter ISO 4217 currency code.
*/
#[ORM\Column(type: 'string', nullable: true)]
public readonly ?string $currency;
public function __construct(
?int $amount = null,
?string $currency = null,
) {
$this->amount = $amount;
$this->currency = $currency;
}
}
+3 -1
View File
@@ -2,9 +2,10 @@
namespace App\Entity\Project;
use App\Entity\EmbeddableMoney as Money;
use App\Entity\Interface\LocalizedEntityInterface;
use App\Entity\Money;
use App\Entity\Trait\LocalizedEntityTrait;
use App\Entity\Trait\MigratedEntity;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\BudgetItemRepository;
use AutoMapper\Attribute\MapProvider;
@@ -17,6 +18,7 @@ use Gedmo\Mapping\Annotation as Gedmo;
#[ORM\Entity(repositoryClass: BudgetItemRepository::class)]
class BudgetItem implements LocalizedEntityInterface
{
use MigratedEntity;
use LocalizedEntityTrait;
#[ORM\Id]
+63 -4
View File
@@ -6,6 +6,9 @@ use App\Entity\Accounting\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Interface\LocalizedEntityInterface;
use App\Entity\Interface\UserOwnedInterface;
use App\Entity\Matchfunding\MatchCallSubmission;
use App\Entity\Matchfunding\MatchCallSubmissionStatus;
use App\Entity\Territory;
use App\Entity\Trait\LocalizedEntityTrait;
use App\Entity\Trait\MigratedEntity;
use App\Entity\Trait\TimestampedCreationEntity;
@@ -22,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Index(fields: ['migratedId'])]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
class Project implements UserOwnedInterface, AccountingOwnerInterface, LocalizedEntityInterface
{
@@ -66,8 +70,8 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
/**
* Project's territory of interest.
*/
#[ORM\Embedded(class: ProjectTerritory::class)]
private ?ProjectTerritory $territory;
#[ORM\Embedded(class: Territory::class)]
private ?Territory $territory;
/**
* The description body for the Project.
@@ -109,6 +113,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
#[ORM\OneToMany(targetEntity: Reward::class, mappedBy: 'project')]
private Collection $rewards;
/**
* @var Collection<int, MatchCallSubmission>
*/
#[ORM\OneToMany(mappedBy: 'project', targetEntity: MatchCallSubmission::class)]
private Collection $matchCallSubmissions;
/**
* @var Collection<int, BudgetItem>
*/
@@ -131,6 +141,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
{
$this->accounting = Accounting::of($this);
$this->rewards = new ArrayCollection();
$this->matchCallSubmissions = new ArrayCollection();
$this->budgetItems = new ArrayCollection();
$this->updates = new ArrayCollection();
$this->supports = new ArrayCollection();
@@ -220,12 +231,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
return $this;
}
public function getTerritory(): ?ProjectTerritory
public function getTerritory(): ?Territory
{
return $this->territory;
}
public function setTerritory(ProjectTerritory $territory): static
public function setTerritory(Territory $territory): static
{
$this->territory = $territory;
@@ -310,6 +321,44 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
return $this;
}
/**
* @return Collection<int, MatchCallSubmission>
*/
public function getMatchCallSubmissions(): Collection
{
return $this->matchCallSubmissions;
}
/**
* @return Collection<int, MatchCallSubmission>
*/
public function getMatchCallSubmissionsBy(MatchCallSubmissionStatus $status): Collection
{
return $this->matchCallSubmissions->filter(fn($s) => $s->getStatus() === $status);
}
public function addMatchCallSubmission(MatchCallSubmission $MatchCallSubmission): static
{
if (!$this->matchCallSubmissions->contains($MatchCallSubmission)) {
$this->matchCallSubmissions->add($MatchCallSubmission);
$MatchCallSubmission->setProject($this);
}
return $this;
}
public function removeMatchCallSubmission(MatchCallSubmission $MatchCallSubmission): static
{
if ($this->matchCallSubmissions->removeElement($MatchCallSubmission)) {
// set the owning side to null (unless already changed)
if ($MatchCallSubmission->getProject() === $this) {
$MatchCallSubmission->setProject(null);
}
}
return $this;
}
/**
* @return Collection<int, BudgetItem>
*/
@@ -348,6 +397,16 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
return $this->updates;
}
/**
* @param Collection<int, Update> $updates
*/
public function setUpdates(Collection $updates): static
{
$this->updates = $updates;
return $this;
}
public function addUpdate(Update $update): static
{
if (!$this->updates->contains($update)) {
+5
View File
@@ -32,6 +32,11 @@ enum ProjectStatus: string
*/
case Rejected = 'rejected';
/**
* An admin reviewed it and deemed worthy to campaign.
*/
case ToCampaign = 'to_campaign';
/**
* Project was reviewed and is in campaign for funding.
*/
+44 -10
View File
@@ -2,8 +2,10 @@
namespace App\Entity\Project;
use App\Entity\Money;
use App\Entity\EmbeddableMoney as Money;
use App\Entity\Interface\LocalizedEntityInterface;
use App\Entity\Trait\LocalizedEntityTrait;
use App\Entity\Trait\MigratedEntity;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\RewardRepository;
use AutoMapper\Attribute\MapProvider;
@@ -19,8 +21,9 @@ use Gedmo\Mapping\Annotation as Gedmo;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Table(name: 'project_reward')]
#[ORM\Entity(repositoryClass: RewardRepository::class)]
class Reward
class Reward implements LocalizedEntityInterface
{
use MigratedEntity;
use LocalizedEntityTrait;
#[ORM\Id]
@@ -50,24 +53,30 @@ class Reward
* Rewards might be finite, i.e: has a limited amount of existing unitsTotal.
*/
#[ORM\Column]
private ?bool $hasUnits = null;
private ?bool $isFinite = null;
/**
* For finite rewards, the total amount of existing unitsTotal.
*/
#[ORM\Column]
#[ORM\Column(nullable: true)]
private ?int $unitsTotal = null;
/**
* The total amount of claims on this Reward.
*/
#[ORM\Column(nullable: true)]
private ?int $unitsClaimed = null;
/**
* For finite rewards, the currently available amount of unitsTotal that can be claimed.
*/
#[ORM\Column]
#[ORM\Column(nullable: true)]
private ?int $unitsAvailable = null;
/**
* @var Collection<int, RewardClaim>
*/
#[ORM\OneToMany(mappedBy: 'reward', targetEntity: RewardClaim::class)]
#[ORM\OneToMany(mappedBy: 'reward', targetEntity: RewardClaim::class, cascade: ['persist'])]
private Collection $claims;
public function __construct()
@@ -128,14 +137,14 @@ class Reward
return $this;
}
public function hasUnits(): bool
public function isFinite(): bool
{
return $this->hasUnits;
return $this->isFinite;
}
public function setHasUnits(bool $hasUnits): static
public function setIsFinite(bool $isFinite): static
{
$this->hasUnits = $hasUnits;
$this->isFinite = $isFinite;
return $this;
}
@@ -152,6 +161,18 @@ class Reward
return $this;
}
public function getUnitsClaimed(): ?int
{
return $this->unitsClaimed;
}
public function setUnitsClaimed(int $unitsClaimed): static
{
$this->unitsClaimed = $unitsClaimed;
return $this;
}
public function getUnitsAvailable(): ?int
{
return $this->unitsAvailable;
@@ -177,6 +198,8 @@ class Reward
if (!$this->claims->contains($claim)) {
$this->claims->add($claim);
$claim->setReward($this);
$this->calcUnits();
}
return $this;
@@ -189,8 +212,19 @@ class Reward
if ($claim->getReward() === $this) {
$claim->setReward(null);
}
$this->calcUnits();
}
return $this;
}
private function calcUnits()
{
$this->unitsClaimed = \count($this->claims);
if ($this->isFinite()) {
$this->unitsAvailable = $this->unitsTotal - $this->unitsClaimed;
}
}
}
+16 -16
View File
@@ -23,35 +23,23 @@ class RewardClaim implements UserOwnedInterface
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Charge $charge = null;
private ?User $owner = null;
#[ORM\ManyToOne(inversedBy: 'claims')]
#[ORM\ManyToOne(inversedBy: 'claims', cascade: ['persist'])]
#[ORM\JoinColumn(nullable: false)]
private ?Reward $reward = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $owner = null;
private ?Charge $charge = null;
public function getId(): ?int
{
return $this->id;
}
public function getCharge(): ?Charge
{
return $this->charge;
}
public function setCharge(Charge $charge): static
{
$this->charge = $charge;
return $this;
}
public function getReward(): ?Reward
{
return $this->reward;
@@ -63,4 +51,16 @@ class RewardClaim implements UserOwnedInterface
return $this;
}
public function getCharge(): ?Charge
{
return $this->charge;
}
public function setCharge(?Charge $charge): static
{
$this->charge = $charge;
return $this;
}
}
+46 -27
View File
@@ -2,17 +2,21 @@
namespace App\Entity\Project;
use App\Entity\Gateway\Charge;
use App\Entity\User\User;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\SupportRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Embedded;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Table(name: 'project_support')]
#[ORM\UniqueConstraint(fields: ['project', 'origin'], name: 'project_origin_unique')]
#[ORM\Entity(repositoryClass: SupportRepository::class)]
class Support
{
@@ -21,32 +25,35 @@ class Support
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'supports')]
#[ORM\JoinColumn(nullable: false)]
private ?User $owner = null;
#[ORM\ManyToOne(inversedBy: 'supports')]
#[ORM\JoinColumn(nullable: false)]
private ?Project $project = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $origin = null;
/**
* @var Collection<int, Charge>
* @var Collection<int, Transaction>
*/
#[ORM\JoinTable(name: 'project_support_charges')]
#[ORM\JoinTable(name: 'project_support_trxs')]
#[ORM\JoinColumn(name: 'support_id', referencedColumnName: 'id')]
#[ORM\InverseJoinColumn(name: 'charge_id', referencedColumnName: 'id')]
#[ORM\ManyToMany(targetEntity: Charge::class)]
private Collection $charges;
#[ORM\InverseJoinColumn(name: 'transaction_id', referencedColumnName: 'id')]
#[ORM\ManyToMany(targetEntity: Transaction::class, cascade: ['persist'])]
private Collection $transactions;
#[Embedded(class: EmbeddableMoney::class)]
private ?EmbeddableMoney $money = null;
#[ORM\Column]
private ?bool $anonymous = null;
private bool $anonymous = false;
#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $message = null;
public function __construct()
{
$this->charges = new ArrayCollection();
$this->transactions = new ArrayCollection();
}
public function getId(): ?int
@@ -54,14 +61,14 @@ class Support
return $this->id;
}
public function getOwner(): ?User
public function getOrigin(): ?Accounting
{
return $this->owner;
return $this->origin;
}
public function setOwner(?User $owner): static
public function setOrigin(?Accounting $origin): static
{
$this->owner = $owner;
$this->origin = $origin;
return $this;
}
@@ -79,30 +86,42 @@ class Support
}
/**
* @return Collection<int, Charge>
* @return Collection<int, Transaction>
*/
public function getCharges(): Collection
public function getTransactions(): Collection
{
return $this->charges;
return $this->transactions;
}
public function addCharge(Charge $charge): static
public function addTransaction(Transaction $transaction): static
{
if (!$this->charges->contains($charge)) {
$this->charges->add($charge);
if (!$this->transactions->contains($transaction)) {
$this->transactions->add($transaction);
}
return $this;
}
public function removeCharge(Charge $charge): static
public function removeTransaction(Transaction $transaction): static
{
$this->charges->removeElement($charge);
$this->transactions->removeElement($transaction);
return $this;
}
public function isAnonymous(): ?bool
public function getMoney(): ?EmbeddableMoney
{
return $this->money;
}
public function setMoney(EmbeddableMoney $money): static
{
$this->money = $money;
return $this;
}
public function isAnonymous(): bool
{
return $this->anonymous;
}
@@ -1,30 +1,39 @@
<?php
namespace App\Entity\Project;
namespace App\Entity;
use App\Validator\CountrySubdivision;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represents the territory of interest for a Project.
*/
#[ORM\Embeddable]
class ProjectTerritory
class Territory
{
public const COUNTRY_UNKNOWN_CODE = 'ZZ';
/**
* ISO 3166-1 alpha-2 two-letter country code.\
* e.g: ES (Spain).
*/
#[Assert\NotBlank()]
#[Assert\Country(alpha3: false)]
#[ORM\Column(type: Types::STRING, nullable: false)]
public readonly string $country;
/**
* First-level sub-division of the country.
* ISO 3166-2 first level subdivision code.\
* e.g: ES-AN (Andalucía, Spain).
*/
#[CountrySubdivision()]
#[ORM\Column(type: Types::STRING, nullable: true)]
public readonly ?string $subLvl1;
/**
* Second-level sub-division of the country.
* ISO 3166-2 second level subdivision code.\
* e.g: ES-GR (Granada, Andalucía, Spain).
*/
#[CountrySubdivision()]
#[ORM\Column(type: Types::STRING, nullable: true)]
public readonly ?string $subLvl2;
@@ -38,8 +47,8 @@ class ProjectTerritory
$this->subLvl2 = $subLvl2 ?? null;
}
public static function unknown(): ProjectTerritory
public static function unknown(): Territory
{
return new ProjectTerritory(self::COUNTRY_UNKNOWN_CODE);
return new Territory(self::COUNTRY_UNKNOWN_CODE);
}
}
-38
View File
@@ -5,7 +5,6 @@ namespace App\Entity\User;
use App\Entity\Accounting\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Project\Project;
use App\Entity\Project\Support;
use App\Entity\Trait\MigratedEntity;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Entity\Trait\TimestampedUpdationEntity;
@@ -112,12 +111,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $avatar = null;
/**
* @var Collection<int, Support>
*/
#[ORM\OneToMany(targetEntity: Support::class, mappedBy: 'owner')]
private Collection $supports;
public function __construct()
{
$this->accounting = Accounting::of($this);
@@ -128,7 +121,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
$this->emailConfirmed = false;
$this->active = false;
$this->supports = new ArrayCollection();
}
public function getId(): ?int
@@ -377,34 +369,4 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
return $this;
}
/**
* @return Collection<int, Support>
*/
public function getSupports(): Collection
{
return $this->supports;
}
public function addSupport(Support $support): static
{
if (!$this->supports->contains($support)) {
$this->supports->add($support);
$support->setOwner($this);
}
return $this;
}
public function removeSupport(Support $support): static
{
if ($this->supports->removeElement($support)) {
// set the owning side to null (unless already changed)
if ($support->getOwner() === $this) {
$support->setOwner(null);
}
}
return $this;
}
}
@@ -1,8 +1,9 @@
<?php
namespace App\Entity;
namespace App\Entity\Wallet;
use App\Repository\WalletFinancementRepository;
use App\Entity\EmbeddableMoney;
use App\Repository\Wallet\WalletFinancementRepository;
use Doctrine\ORM\Mapping as ORM;
/**
@@ -19,8 +20,8 @@ class WalletFinancement
/**
* The monetary value used in this Financement.
*/
#[ORM\Embedded(class: Money::class)]
private ?Money $money = null;
#[ORM\Embedded(class: EmbeddableMoney::class)]
private ?EmbeddableMoney $money = null;
/**
* An incoming Statement that originally saved the money in this Financement.
@@ -41,12 +42,12 @@ class WalletFinancement
return $this->id;
}
public function getMoney(): ?Money
public function getMoney(): ?EmbeddableMoney
{
return $this->money;
}
public function setMoney(Money $money): static
public function setMoney(EmbeddableMoney $money): static
{
$this->money = $money;
@@ -1,10 +1,11 @@
<?php
namespace App\Entity;
namespace App\Entity\Wallet;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Gateway\Wallet\StatementDirection;
use App\Repository\WalletStatementRepository;
use App\Repository\Wallet\WalletStatementRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -40,8 +41,8 @@ class WalletStatement
* 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;
#[ORM\Embedded(class: EmbeddableMoney::class)]
private ?EmbeddableMoney $balance = null;
/**
* A collection of Financements for outgoing Statements that were made using money from this Statement.
@@ -103,12 +104,12 @@ class WalletStatement
return $this;
}
public function getBalance(): ?Money
public function getBalance(): ?EmbeddableMoney
{
return $this->balance;
}
public function setBalance(Money $balance): static
public function setBalance(EmbeddableMoney $balance): static
{
$this->balance = $balance;
@@ -0,0 +1,27 @@
<?php
namespace App\EventListener;
use App\Entity\Accounting\Accounting;
use App\Money\MoneyService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
#[AsEntityListener(
entity: Accounting::class,
method: 'prePersist',
event: Events::prePersist
)]
class AccountingDefaultsListener
{
public function __construct(
private MoneyService $moneyService,
) {}
public function prePersist(Accounting $accounting): void
{
if ($accounting->getCurrency() === null) {
$accounting->setCurrency($this->moneyService->getDefaultCurrency());
}
}
}
@@ -2,74 +2,57 @@
namespace App\EventListener;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Project\Project;
use App\Entity\Project\Support;
use App\Entity\User\User;
use App\Money\Money;
use App\Money\MoneyService;
use App\Repository\Project\SupportRepository;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::preUpdate,
method: 'preUpdate',
entity: Checkout::class
)]
#[AsEntityListener(
event: Events::postUpdate,
method: 'postUpdate',
event: Events::preFlush,
method: 'preFlush',
entity: Checkout::class
)]
class GatewayCheckoutSupportsListener
{
/** @var array<int, Support> */
private array $supports;
public function __construct(
private SupportRepository $supportRepository,
private MoneyService $moneyService,
) {}
public function preUpdate(Checkout $checkout, PreUpdateEventArgs $args): void
public function preFlush(Checkout $checkout, PreFlushEventArgs $args): void
{
if (!$args->hasChangedField('status')) {
return;
/** @var EntityManagerInterface */
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Checkout) {
continue;
}
$changes = $uow->getEntityChangeSet($entity);
if (!isset($changes['status']) || !$entity->isCharged()) {
continue;
}
$supports = $this->prepareSupports($entity);
foreach ($supports as $support) {
$em->persist($support);
$uow->computeChangeSet($em->getClassMetadata(Support::class), $support);
}
}
if ($checkout->isCharged()) {
$this->supports = $this->prepareSupports($checkout, $args->getObjectManager());
}
}
public function postUpdate(Checkout $checkout, PostUpdateEventArgs $args): void
{
if (empty($this->supports)) {
return;
}
foreach ($this->supports as $key => $support) {
$args->getObjectManager()->persist($support);
unset($this->supports[$key]);
}
$args->getObjectManager()->flush();
}
/**
* Create ProjectSupport for the given data.
*
* @param Charge[] $charges
*/
private function createSupport(Project $project, User $owner, array $charges): Support
{
$projectSupport = new Support();
$projectSupport->setProject($project);
$projectSupport->setOwner($owner);
$projectSupport->setAnonymous(false);
foreach ($charges as $charge) {
$projectSupport->addCharge($charge);
}
return $projectSupport;
}
/**
@@ -77,26 +60,66 @@ class GatewayCheckoutSupportsListener
*/
public function prepareSupports(Checkout $checkout): array
{
/** @var Charge[] */
$charges = $checkout->getCharges()->toArray();
$owner = $checkout->getOrigin()->getUser();
$origin = $checkout->getOrigin();
// Group charges for projects
$chargesInProjectMap = [];
$projects = [];
$transactionsByProject = [];
foreach ($charges as $charge) {
$project = $charge->getTarget()->getProject();
if ($project === null) {
if (!$project) {
continue;
}
$chargesInProjectMap[$project->getId()][] = $charge;
$projectId = $project->getId();
$projects[$projectId] = $project;
$transactionsByProject[$projectId] = [
...$transactionsByProject[$projectId] ?? [],
...$charge->getTransactions(),
];
}
$supports = [];
foreach ($chargesInProjectMap as $chargesInProject) {
$project = $chargesInProject[0]->getTarget()->getProject();
$supports[] = $this->createSupport($project, $owner, $chargesInProject);
foreach ($transactionsByProject as $projectId => $transactions) {
$supports[] = $this->getSupport($projects[$projectId], $origin, $transactions);
}
return $supports;
}
/**
* Create ProjectSupport for the given data.
*
* @param Transaction[] $transactions
*/
private function getSupport(Project $project, Accounting $origin, array $transactions): Support
{
/** @var Support|null */
$projectSupport = $this->supportRepository->findOneBy([
'project' => $project,
'origin' => $origin,
]);
if (!$projectSupport) {
$projectSupport = new Support();
}
$projectSupport->setProject($project);
$projectSupport->setOrigin($origin);
$projectSupport->setAnonymous(false);
$money = new Money(0, $project->getAccounting()->getCurrency());
foreach ($transactions as $transaction) {
$money = $this->moneyService->add($transaction->getMoney(), $money);
$projectSupport->addTransaction($transaction);
}
$projectSupport->setMoney(EmbeddableMoney::of($money));
return $projectSupport;
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\EventListener;
use App\Entity\Gateway\Charge;
use App\Gateway\ChargeStatus;
use App\Service\Matchfunding\MatchfundingService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::preUpdate,
method: 'preUpdate',
entity: Charge::class
)]
#[AsEntityListener(
event: Events::postUpdate,
method: 'postUpdate',
entity: Charge::class
)]
final class MatchfundingListener
{
private ?Charge $charge = null;
public function __construct(
private MatchfundingService $matchfunding,
private EntityManagerInterface $entityManager,
) {}
public function preUpdate(Charge $charge, PreUpdateEventArgs $event)
{
$this->charge = null;
if (count($event->getEntityChangeSet()) === 0) {
return;
}
if ($charge->getStatus() !== ChargeStatus::Charged) {
return;
}
$transactions = $this->matchfunding->match($charge);
if (\count($transactions) > 0) {
foreach ($transactions as $transaction) {
$charge->addTransaction($transaction);
}
$this->charge = $charge;
}
}
public function postUpdate()
{
if ($this->charge === null) {
return;
}
$this->entityManager->persist($this->charge);
$this->entityManager->flush();
$this->charge = null;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\EventListener;
use App\Entity\Accounting\Transaction;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
#[AsEntityListener(
entity: Transaction::class,
event: Events::prePersist
)]
class TransactionCurrencyListener
{
public function prePersist(Transaction $transaction): void
{
$money = $transaction->getMoney();
$target = $transaction->getTarget();
if ($money->getCurrency() !== $target->getCurrency()) {
throw new \LogicException(\sprintf(
'The Accounting with ID %s can only receive Transactions in %s currency. Please do a conversion operation before targeting an Accounting in a different currency.',
$target->getId(),
$target->getCurrency()
));
}
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\EventSubscriber;
use ApiPlatform\Symfony\EventListener\EventPriorities;
use App\ApiResource\User\UserTokenApiResource;
use App\Service\Auth\AuthService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class AuthorizationTokenSubscriber implements EventSubscriberInterface
{
public function __construct(
private AuthService $authService,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::VIEW => ['onView', EventPriorities::POST_WRITE],
KernelEvents::RESPONSE => ['onResponse', EventPriorities::POST_RESPOND],
];
}
public function onView(ViewEvent $event): void
{
$resourceClass = $event->getRequest()->attributes->get('_api_resource_class');
if ($resourceClass !== UserTokenApiResource::class) {
return;
}
$event->getRequest()->attributes->set('authTokenMethod', $event->getRequest()->getMethod());
if ($event->getRequest()->getMethod() === Request::METHOD_POST) {
$token = $event->getControllerResult();
$event->getRequest()->attributes->set('authTokenValue', $token->token);
}
}
public function onResponse(ResponseEvent $event): void
{
$token = $event->getRequest()->attributes->get('authTokenValue');
$method = $event->getRequest()->attributes->get('authTokenMethod');
if (!$method) {
return;
}
switch ($method) {
case Request::METHOD_DELETE:
$event->getResponse()->headers->clearCookie(AuthService::AUTH_COOKIE_NAME);
break;
case Request::METHOD_POST:
$event->getResponse()->headers->setCookie($this->authService->generateCookie($token));
break;
}
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ use App\Entity\Project\Project;
use App\Entity\Project\ProjectCategory;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\ProjectTerritory;
use App\Entity\Territory;
use App\Entity\User\User;
use App\Factory\User\UserFactory;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
@@ -44,7 +44,7 @@ final class ProjectFactory extends PersistentProxyObjectFactory
'category' => ProjectCategory::Design,
'deadline' => ProjectDeadline::Minimum,
'description' => '',
'territory' => new ProjectTerritory('ES'),
'territory' => new Territory('ES'),
'owner' => new User(),
'status' => ProjectStatus::InEditing,
'subtitle' => 'Subtitle',
@@ -58,7 +58,7 @@ final class ProjectFactory extends PersistentProxyObjectFactory
'category' => self::faker()->randomElement(ProjectCategory::cases()),
'deadline' => self::faker()->randomElement(ProjectDeadline::cases()),
'description' => self::faker()->text(),
'territory' => new ProjectTerritory('ES'),
'territory' => new Territory('ES'),
'owner' => UserFactory::createWithMode(),
'status' => self::faker()->randomElement(ProjectStatus::cases()),
'subtitle' => self::faker()->text(255),
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use App\ApiResource\Gateway\GatewayApiResource;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
final class GatewayFilter extends AbstractFilter
{
public function __construct(
private IriConverterInterface $iriConverter,
protected ?ManagerRegistry $managerRegistry,
?LoggerInterface $logger = null,
protected ?array $properties = null,
protected ?NameConverterInterface $nameConverter = null,
) {
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
}
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (
!$this->isPropertyEnabled($property, $resourceClass)
) {
return;
}
$values = (array) $value;
$rootAlias = $queryBuilder->getRootAliases()[0];
$parameterName = ':'.$queryNameGenerator->generateParameterName($property);
if ($this->isPropertyNested($property, $resourceClass)) {
[$alias] = $this->addJoinsForNestedProperty($property, $rootAlias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN);
}
$aliasedField = \sprintf('%s.gatewayName', $alias);
if (\count($values) === 1) {
$queryBuilder
->andWhere($queryBuilder->expr()->eq($aliasedField, $parameterName))
->setParameter($parameterName, $this->getGatewayName($values[0]));
return;
}
$queryBuilder
->andWhere($queryBuilder->expr()->in($aliasedField, $parameterName))
->setParameter($parameterName, array_map(fn($v) => $this->getGatewayName($v), $values));
return;
}
private function getGatewayName(mixed $value): string
{
/** @var GatewayApiResource */
$gateway = $this->iriConverter->getResourceFromIri($value);
return $gateway->name;
}
public function getDescription(string $resourceClass): array
{
$description = [];
foreach ($this->properties as $property => $strategy) {
$description[$property] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'is_collection' => false,
];
$description[$property.'[]'] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_ARRAY,
'required' => false,
'is_collection' => true,
];
}
return $description;
}
}
+31 -1
View File
@@ -2,12 +2,42 @@
namespace App\Gateway\Gateway;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Gateway\GatewayInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
abstract class CashGateway implements GatewayInterface
class CashGateway implements GatewayInterface
{
public static function getName(): string
{
return 'cash';
}
public static function getSupportedChargeTypes(): array
{
return [];
}
public function process(Checkout $checkout): Checkout
{
return $checkout;
}
public function handleRedirect(Request $request): RedirectResponse
{
return new Response();
}
public function handleWebhook(Request $request): Response
{
return new Response();
}
public function processRefund(Charge $charge): void
{
return;
}
}
+36 -1
View File
@@ -2,12 +2,47 @@
namespace App\Gateway\Gateway;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Gateway\ChargeType;
use App\Gateway\GatewayInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
abstract class CecaGateway implements GatewayInterface
class CecaGateway implements GatewayInterface
{
public const LEGACY_MESSAGE = 'This Gateway implementation is only meant to give support to legacy-records. DO NOT USE. DO NOT IMPLEMENT.';
public static function getName(): string
{
return 'tpv';
}
public static function getSupportedChargeTypes(): array
{
return [
ChargeType::Single,
];
}
public function process(Checkout $checkout): Checkout
{
throw new \Exception(self::LEGACY_MESSAGE);
}
public function handleRedirect(Request $request): RedirectResponse
{
throw new \Exception(self::LEGACY_MESSAGE);
}
public function handleWebhook(Request $request): Response
{
throw new \Exception(self::LEGACY_MESSAGE);
}
public function processRefund(Charge $charge): void
{
throw new \Exception(self::LEGACY_MESSAGE);
}
}
+15 -16
View File
@@ -4,12 +4,12 @@ namespace App\Gateway\Paypal;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Gateway\Tracking;
use App\Gateway\AbstractGateway;
use App\Gateway\ChargeType;
use App\Gateway\CheckoutStatus;
use App\Gateway\Link;
use App\Gateway\LinkType;
use App\Gateway\Tracking;
use Brick\Money\Money as BrickMoney;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -64,9 +64,7 @@ class PaypalGateway extends AbstractGateway
'payment_source' => $this->getPaypalPaymentSource($checkout),
]);
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_ORDER;
$tracking->value = $order['id'];
$tracking = new Tracking(self::TRACKING_TITLE_ORDER, $order['id']);
$checkout->addTracking($tracking);
@@ -111,9 +109,10 @@ class PaypalGateway extends AbstractGateway
}
foreach ($capture['purchase_units'] as $purchaseUnit) {
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_TRANSACTION;
$tracking->value = $purchaseUnit['payments']['captures'][0]['id'];
$tracking = new Tracking(
self::TRACKING_TITLE_TRANSACTION,
$purchaseUnit['payments']['captures'][0]['id']
);
$checkout->addTracking($tracking);
}
@@ -189,9 +188,10 @@ class PaypalGateway extends AbstractGateway
}
foreach ($event['resource']['purchase_units'] as $purchaseUnit) {
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_TRANSACTION;
$tracking->value = $purchaseUnit['payments']['captures'][0]['id'];
$tracking = new Tracking(
self::TRACKING_TITLE_TRANSACTION,
$purchaseUnit['payments']['captures'][0]['id']
);
$checkout->addTracking($tracking);
}
@@ -263,16 +263,15 @@ class PaypalGateway extends AbstractGateway
public function processRefund(Charge $charge): void
{
$trackings = $charge->getCheckout()->getTrackings();
$captureTracking = current(array_filter(
$trackings,
fn(Tracking $tracking) => $tracking->title === self::TRACKING_TITLE_TRANSACTION && $tracking->value
));
$captureTracking = $trackings->filter(function (Tracking $t) {
return $t->getTitle() === self::TRACKING_TITLE_TRANSACTION && $t->getValue();
})->current();
if (!$captureTracking) {
throw new \Exception('PayPal capture ID not found in tracking.');
throw new \Exception('Tracking for PayPal capture ID not found.');
}
$captureId = $captureTracking->value;
$captureId = $captureTracking->getValue();
$response = $this->paypal->refundCapture($captureId, [
'amount' => $this->getPaypalMoney($charge),
+8 -20
View File
@@ -4,6 +4,7 @@ namespace App\Gateway\Stripe;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Gateway\Tracking;
use App\Entity\Project\Project;
use App\Entity\User\User;
use App\Gateway\AbstractGateway;
@@ -11,7 +12,6 @@ use App\Gateway\ChargeType;
use App\Gateway\CheckoutStatus;
use App\Gateway\Link;
use App\Gateway\LinkType;
use App\Gateway\Tracking;
use App\Service\Gateway\CheckoutService;
use Stripe\Checkout\Session as StripeSession;
use Stripe\StripeClient;
@@ -71,12 +71,7 @@ class StripeGateway extends AbstractGateway
$link->type = LinkType::Payment;
$checkout->addLink($link);
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_CHECKOUT;
$tracking->value = $session->id;
$checkout->addTracking($tracking);
$checkout->addTracking(new Tracking(self::TRACKING_TITLE_CHECKOUT, $session->id));
return $checkout;
}
@@ -209,22 +204,15 @@ class StripeGateway extends AbstractGateway
public function processRefund(Charge $charge): void
{
$trackings = $charge->getCheckout()->getTrackings();
$filteredTrackings = array_filter(
$trackings,
fn(Tracking $tracking) => $tracking->title === self::TRACKING_TITLE_CHECKOUT
);
$sessionTracking = $trackings->filter(function (Tracking $t) {
return $t->getTitle() === self::TRACKING_TITLE_CHECKOUT;
})->current();
$firstTracking = $filteredTrackings[0] ?? null;
if (!$firstTracking || $firstTracking->title !== self::TRACKING_TITLE_CHECKOUT) {
throw new \Exception('Tracking not found or not related to Stripe checkout.');
if (!$sessionTracking) {
throw new \Exception('Tracking for Stripe Session ID not found.');
}
$sessionId = $firstTracking->value;
if (!$sessionId) {
throw new \Exception('Session ID not found for the charge.');
}
$sessionId = $sessionTracking->getValue();
$session = $this->stripe->checkout->sessions->retrieve($sessionId);
-17
View File
@@ -2,32 +2,15 @@
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;
public static function tryFrom($value): Tracking
{
if ($value instanceof Tracking) {
return $value;
}
$tracking = new Tracking();
$tracking->title = $value['title'];
$tracking->value = $value['value'];
return $tracking;
}
}
+2 -2
View File
@@ -5,12 +5,12 @@ namespace App\Gateway\Wallet;
use App\Entity\Accounting\Transaction;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Money;
use App\Gateway\ChargeStatus;
use App\Gateway\ChargeType;
use App\Gateway\CheckoutStatus;
use App\Gateway\GatewayInterface;
use App\Library\Economy\MoneyService;
use App\Money\Money;
use App\Money\MoneyService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

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