diff --git a/src/ApiResource/Version.php b/src/ApiResource/Version.php index f37b79e..103e649 100644 --- a/src/ApiResource/Version.php +++ b/src/ApiResource/Version.php @@ -3,10 +3,10 @@ namespace App\ApiResource; use ApiPlatform\Metadata as API; -use App\Filter\VersionResourceFilter; -use App\Filter\VersionResourceIdFilter; +use App\Filter\ResourceVersionResourceFilter; +use App\Filter\ResourceVersionResourceIdFilter; use App\Service\ApiResourceNormalizer; -use App\State\VersionStateProvider; +use App\State\ResourceVersionStateProvider; use Gedmo\Loggable\Entity\LogEntry; /** @@ -15,10 +15,10 @@ use Gedmo\Loggable\Entity\LogEntry; * This allows us to keep track of the flow and the evolution of records in the platform. * Looking at the changes done between one version and the next one we can reconstruct how a resource was at a certain point in time. */ -#[API\ApiFilter(VersionResourceFilter::class, properties: ['resource'])] -#[API\ApiFilter(VersionResourceIdFilter::class, properties: ['resourceId'])] -#[API\GetCollection(provider: VersionStateProvider::class)] -#[API\Get(provider: VersionStateProvider::class)] +#[API\ApiFilter(ResourceVersionResourceFilter::class, properties: ['resource'])] +#[API\ApiFilter(ResourceVersionResourceIdFilter::class, properties: ['resourceId'])] +#[API\GetCollection(provider: ResourceVersionStateProvider::class)] +#[API\Get(provider: ResourceVersionStateProvider::class)] class Version { public function __construct( @@ -67,21 +67,6 @@ class Version return $this->log->getObjectId(); } - /** - * The full resource data, reconstructed from the current resource data merged with versioned data. - */ - public function getResourceData() - { - $entity = $this->entity; - - foreach ($this->log->getData() as $changedProperty => $changedValue) { - $setter = sprintf("set%s", ucfirst($changedProperty)); - $entity->$setter($changedValue); - } - - return $entity; - } - /** * The changed resource data, i.e the new values of the changed properties. */ diff --git a/src/DependencyInjection/Compiler/VersionedResourcePass.php b/src/DependencyInjection/Compiler/VersionedResourcePass.php new file mode 100644 index 0000000..84272ed --- /dev/null +++ b/src/DependencyInjection/Compiler/VersionedResourcePass.php @@ -0,0 +1,39 @@ +getAttributes(Gedmo\Loggable::class); + + if (empty($attributes)) { + continue; + } + + $versionedResourceNames = [ + ...$versionedResourceNames, + ApiResourceNormalizer::toResource($entityClass) + ]; + } + + /** @var VersionedResourceService */ + $versionedResourceService = $container->get(VersionedResourceService::class); + $versionedResourceService->compileNames($versionedResourceNames); + } +} diff --git a/src/Dto/GatewayCheckoutUpdateDto.php b/src/Dto/GatewayCheckoutUpdateDto.php new file mode 100644 index 0000000..1ce5e7f --- /dev/null +++ b/src/Dto/GatewayCheckoutUpdateDto.php @@ -0,0 +1,29 @@ +status = $status; + $this->gatewayReference = $gatewayReference; + } +} diff --git a/src/Entity/Accounting.php b/src/Entity/Accounting.php index 54bae0a..89bc2b5 100644 --- a/src/Entity/Accounting.php +++ b/src/Entity/Accounting.php @@ -34,12 +34,8 @@ class Accounting private ?string $currency = null; #[API\ApiProperty(writable: false)] - #[ORM\OneToMany(mappedBy: 'origin', targetEntity: Transaction::class)] - private Collection $transactionsIssued; - - #[API\ApiProperty(writable: false)] - #[ORM\OneToMany(mappedBy: 'target', targetEntity: Transaction::class)] - private Collection $transactionsReceived; + #[ORM\OneToMany(mappedBy: 'accounting', targetEntity: AccountingStatement::class)] + private Collection $statements; #[API\ApiProperty(writable: false, readable: false)] #[ORM\Column(length: 255)] @@ -51,6 +47,9 @@ class Accounting #[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist', 'remove'])] private ?Project $project = null; + #[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist', 'remove'])] + private ?Tipjar $tipjar = null; + public function __construct() { /** @@ -58,9 +57,7 @@ class Accounting * ideally a configuration that can be updated via a frontend, not env var only */ $this->currency = 'EUR'; - - $this->transactionsIssued = new ArrayCollection(); - $this->transactionsReceived = new ArrayCollection(); + $this->statements = new ArrayCollection(); } public function getId(): ?int @@ -81,59 +78,29 @@ class Accounting } /** - * @return Collection + * @return Collection */ - public function getTransactionsIssued(): Collection + public function getStatements(): Collection { - return $this->transactionsIssued; + return $this->statements; } - public function addTransactionsIssued(Transaction $transactionsIssued): static + public function addStatement(AccountingStatement $statement): static { - if (!$this->transactionsIssued->contains($transactionsIssued)) { - $this->transactionsIssued->add($transactionsIssued); - $transactionsIssued->setOrigin($this); + if (!$this->statements->contains($statement)) { + $this->statements->add($statement); + $statement->setAccounting($this); } return $this; } - public function removeTransactionsIssued(Transaction $transactionsIssued): static + public function removeStatement(AccountingStatement $statement): static { - if ($this->transactionsIssued->removeElement($transactionsIssued)) { + if ($this->statements->removeElement($statement)) { // set the owning side to null (unless already changed) - if ($transactionsIssued->getOrigin() === $this) { - $transactionsIssued->setOrigin(null); - } - } - - return $this; - } - - /** - * @return Collection - */ - public function getTransactionsReceived(): Collection - { - return $this->transactionsReceived; - } - - public function addTransactionsReceived(Transaction $transactionsReceived): static - { - if (!$this->transactionsReceived->contains($transactionsReceived)) { - $this->transactionsReceived->add($transactionsReceived); - $transactionsReceived->setTarget($this); - } - - return $this; - } - - public function removeTransactionsReceived(Transaction $transactionsReceived): static - { - if ($this->transactionsReceived->removeElement($transactionsReceived)) { - // set the owning side to null (unless already changed) - if ($transactionsReceived->getTarget() === $this) { - $transactionsReceived->setTarget(null); + if ($statement->getAccounting() === $this) { + $statement->setAccounting(null); } } @@ -204,4 +171,24 @@ class Accounting return $this; } + + public function getTipjar(): ?Tipjar + { + return $this->tipjar; + } + + public function setTipjar(Tipjar $tipjar): static + { + // set the owning side of the relation if necessary + if ($tipjar->getAccounting() !== $this) { + $tipjar->setAccounting($this); + } + + // set the owner class + $this->setOwnerClass($tipjar::class); + + $this->tipjar = $tipjar; + + return $this; + } } diff --git a/src/Entity/AccountingStatement.php b/src/Entity/AccountingStatement.php new file mode 100644 index 0000000..0d1daef --- /dev/null +++ b/src/Entity/AccountingStatement.php @@ -0,0 +1,116 @@ + new API\Link(fromClass: Accounting::class, toProperty: 'accounting'), + ], +)] +#[API\Get( + uriTemplate: '/accountings/{id}/statements/{statementId}', + uriVariables: [ + 'id' => new API\Link(fromClass: Accounting::class, toProperty: 'accounting'), + 'statementId' => new API\Link(fromClass: AccountingStatement::class) + ], +)] +#[ORM\Entity(repositoryClass: AccountingStatementRepository::class)] +class AccountingStatement +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[API\ApiProperty(readable: false)] + #[ORM\ManyToOne(inversedBy: 'statements')] + #[ORM\JoinColumn(nullable: false)] + private ?Accounting $accounting = null; + + /** + * The money held in this Statement. + */ + #[ORM\Embedded(class: Money::class)] + private ?Money $money = null; + + /** + * The direction of this Statement.\ + * `incoming` means the transaction was received and the money is available.\ + * `outgoing` means the transaction was issued and the money is an expenditure. + */ + #[ORM\Column()] + private ?AccountingStatementDirection $direction = null; + + /** + * The Transaction represented in this Statement. + */ + #[ORM\OneToOne(cascade: ['persist', 'remove'])] + #[ORM\JoinColumn(nullable: false)] + private ?AccountingTransaction $transaction = null; + + 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; + } + + public function getMoney(): ?Money + { + return $this->money; + } + + public function setMoney(Money $money): static + { + $this->money = $money; + + return $this; + } + + public function getDirection(): ?AccountingStatementDirection + { + return $this->direction; + } + + public function setDirection(AccountingStatementDirection $direction): static + { + $this->direction = $direction; + + return $this; + } + + public function getTransaction(): ?AccountingTransaction + { + return $this->transaction; + } + + public function setTransaction(AccountingTransaction $transaction): static + { + $this->transaction = $transaction; + + return $this; + } +} diff --git a/src/Entity/AccountingStatementDirection.php b/src/Entity/AccountingStatementDirection.php new file mode 100644 index 0000000..d5a80cb --- /dev/null +++ b/src/Entity/AccountingStatementDirection.php @@ -0,0 +1,10 @@ +id; @@ -101,28 +84,4 @@ class Transaction return $this; } - - public function getGateway(): ?string - { - return $this->gateway; - } - - public function setGateway(string $gateway): static - { - $this->gateway = $gateway; - - return $this; - } - - public function getGatewayReference(): ?string - { - return $this->gatewayReference; - } - - public function setGatewayReference(string $gatewayReference): static - { - $this->gatewayReference = $gatewayReference; - - return $this; - } } diff --git a/src/Entity/GatewayCharge.php b/src/Entity/GatewayCharge.php index 7ae246a..6bed9ab 100644 --- a/src/Entity/GatewayCharge.php +++ b/src/Entity/GatewayCharge.php @@ -2,11 +2,13 @@ namespace App\Entity; -use ApiPlatform\Metadata as API; use App\Repository\GatewayChargeRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +/** + * A GatewayCharge represents a monetary payment that can be done by an issuer at checkout with the Gateway. + */ #[ORM\Entity(repositoryClass: GatewayChargeRepository::class)] class GatewayCharge { @@ -16,37 +18,44 @@ class GatewayCharge private ?int $id = null; /** - * The money to be charged by the Gateway at checkout. + * The type of a GatewayCharge represents the kind of payment. */ #[Assert\NotBlank()] - #[ORM\Embedded(class: Money::class)] + #[ORM\Column()] + private ?GatewayChargeType $type = null; + + /** + * The charged monetary sum. + */ + #[Assert\NotBlank()] + #[ORM\Embedded(Money::class)] private ?Money $money = null; /** - * The Accounting that will receive the Transaction of this GatewayCharge after a successful checkout. + * The Accounting receiving the consequent Transaction for this GatewayCharge. */ #[Assert\NotBlank()] #[ORM\ManyToOne] #[ORM\JoinColumn(nullable: false)] private ?Accounting $target = null; - /** - * Custom data to be passed to the Gateway.\ - * E.g: billing period, product name, custom metadata, etc. - */ - #[ORM\Column(nullable: true)] - private ?array $extradata = null; - - #[API\ApiProperty(writable: false)] - #[ORM\ManyToOne(inversedBy: 'charges')] - #[ORM\JoinColumn(nullable: false)] - private ?GatewayCheckout $checkout = null; - public function getId(): ?int { return $this->id; } + public function getType(): ?GatewayChargeType + { + return $this->type; + } + + public function setType(GatewayChargeType $type): static + { + $this->type = $type; + + return $this; + } + public function getMoney(): ?Money { return $this->money; @@ -70,28 +79,4 @@ class GatewayCharge return $this; } - - public function getExtradata(): ?array - { - return $this->extradata; - } - - public function setExtradata(?array $extradata): static - { - $this->extradata = $extradata; - - return $this; - } - - public function getCheckout(): ?GatewayCheckout - { - return $this->checkout; - } - - public function setCheckout(?GatewayCheckout $checkout): static - { - $this->checkout = $checkout; - - return $this; - } } diff --git a/src/Entity/GatewayChargeType.php b/src/Entity/GatewayChargeType.php new file mode 100644 index 0000000..84c7ba1 --- /dev/null +++ b/src/Entity/GatewayChargeType.php @@ -0,0 +1,16 @@ + ['default', 'postValidation']])] #[API\Get()] +#[API\Patch(input: GatewayCheckoutUpdateDto::class, processor: GatewayCheckoutUpdateProcessor::class)] +#[API\ApiFilter(filterClass: SearchFilter::class, properties: ['origin' => 'exact', 'charges.target' => 'exact'])] #[ORM\Entity(repositoryClass: GatewayCheckoutRepository::class)] class GatewayCheckout { + use TimestampableEntity; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; - /** - * The name of the Gateway implementation to checkout with. - */ - #[GatewayName] - #[Assert\NotBlank()] - #[ORM\Column(length: 255)] - private ?string $gateway = null; - /** * The Accounting that will issue the Transactions of the GatewayCharges after a successful checkout. */ #[Assert\NotBlank()] + #[API\ApiProperty(readableLink: true)] #[ORM\ManyToOne] #[ORM\JoinColumn(nullable: false)] private ?Accounting $origin = null; /** - * The charges to be made by the Gateway at checkout.\ - * Each GatewayCharge will generate a separate Transaction after a successful checkout. + * The GatewayCharges to be charged at checkout with the Gateway. */ #[Assert\NotBlank()] #[Assert\Count(min: 1)] - #[ORM\OneToMany( - mappedBy: 'checkout', - targetEntity: GatewayCharge::class, - cascade: ['persist'] - )] + #[API\ApiProperty(readableLink: true, writableLink: true)] + #[ORM\ManyToMany(targetEntity: GatewayCharge::class, cascade: ['persist'])] private Collection $charges; + /** + * The status of the checkout with the Gateway. + */ + #[Gedmo\Versioned] + #[API\ApiProperty(writable: false)] + #[API\ApiFilter(SearchFilter::class)] + #[ORM\Column()] + private ?GatewayCheckoutStatus $status = null; + + /** + * The name of the Gateway implementation to checkout with. + */ + #[GatewayName] + #[Assert\NotBlank()] + #[API\ApiFilter(SearchFilter::class)] + #[ORM\Column(length: 255)] + private ?string $gateway = null; + + /** + * An external identifier provided by the Gateway for the payment.\ + * Required when a GatewayCheckout is completed. + */ + #[Assert\NotBlank(['groups' => ['postValidation']])] + #[API\ApiFilter(SearchFilter::class)] + #[ORM\Column(length: 255)] + private ?string $gatewayReference = null; + + /** + * The URL where the checkout with the Gateway is available. + */ + #[API\ApiProperty(writable: false)] + #[ORM\Column(type: Types::TEXT)] + private ?string $checkoutUrl = null; + + /** + * GatewayCheckout was migrated from an invest record in Goteo v3 platform. + */ + #[API\ApiProperty(writable: false)] + #[ORM\Column] + private ?bool $migrated = null; + + /** + * The id of the original invest record in the Goteo v3 platform. + */ + #[API\ApiProperty(writable: false)] + #[ORM\Column(length: 255, nullable: true)] + private ?string $migratedReference = null; + + /** + * A free-form collection of additional data associated with this checkout operation. + */ + #[ORM\Column(nullable: true)] + private ?array $metadata = null; + public function __construct() { $this->charges = new ArrayCollection(); + $this->status = GatewayCheckoutStatus::Pending; } public function getId(): ?int @@ -65,18 +120,6 @@ class GatewayCheckout return $this->id; } - public function getGateway(): ?string - { - return $this->gateway; - } - - public function setGateway(string $gateway): static - { - $this->gateway = $gateway; - - return $this; - } - public function getOrigin(): ?Accounting { return $this->origin; @@ -101,7 +144,6 @@ class GatewayCheckout { if (!$this->charges->contains($charge)) { $this->charges->add($charge); - $charge->setCheckout($this); } return $this; @@ -109,12 +151,91 @@ class GatewayCheckout public function removeCharge(GatewayCharge $charge): static { - if ($this->charges->removeElement($charge)) { - // set the owning side to null (unless already changed) - if ($charge->getCheckout() === $this) { - $charge->setCheckout(null); - } - } + $this->charges->removeElement($charge); + + return $this; + } + + public function getStatus(): ?GatewayCheckoutStatus + { + return $this->status; + } + + public function setStatus(GatewayCheckoutStatus $status): static + { + $this->status = $status; + + return $this; + } + + public function getGateway(): ?string + { + return $this->gateway; + } + + public function setGateway(string $gateway): static + { + $this->gateway = $gateway; + + return $this; + } + + public function getGatewayReference(): ?string + { + return $this->gatewayReference; + } + + public function setGatewayReference(string $gatewayReference): static + { + $this->gatewayReference = $gatewayReference; + + return $this; + } + + public function getCheckoutUrl(): ?string + { + return $this->checkoutUrl; + } + + public function setCheckoutUrl(string $checkoutUrl): static + { + $this->checkoutUrl = $checkoutUrl; + + return $this; + } + + public function isMigrated(): ?bool + { + return $this->migrated; + } + + public function setMigrated(bool $migrated): static + { + $this->migrated = $migrated; + + return $this; + } + + public function getMigratedReference(): ?string + { + return $this->migratedReference; + } + + public function setMigratedReference(?string $migratedReference): static + { + $this->migratedReference = $migratedReference; + + return $this; + } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function setMetadata(?array $metadata): static + { + $this->metadata = $metadata; return $this; } diff --git a/src/Entity/GatewayCheckoutStatus.php b/src/Entity/GatewayCheckoutStatus.php new file mode 100644 index 0000000..4e8bc86 --- /dev/null +++ b/src/Entity/GatewayCheckoutStatus.php @@ -0,0 +1,10 @@ +owner; } - public function setOwner(User $owner): static + public function setOwner(?User $owner): static { $this->owner = $owner; diff --git a/src/Entity/Tipjar.php b/src/Entity/Tipjar.php new file mode 100644 index 0000000..57507f9 --- /dev/null +++ b/src/Entity/Tipjar.php @@ -0,0 +1,63 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getAccounting(): ?Accounting + { + return $this->accounting; + } + + public function setAccounting(Accounting $accounting): static + { + $this->accounting = $accounting; + + return $this; + } +} diff --git a/src/Entity/TransactionGateway.php b/src/Entity/TransactionGateway.php deleted file mode 100644 index 92962f3..0000000 --- a/src/Entity/TransactionGateway.php +++ /dev/null @@ -1,68 +0,0 @@ -name; - } - - public function setName(string $name): static - { - $this->name = $name; - - return $this; - } - - public function getSuccessUrl(): ?string - { - return $this->successUrl; - } - - public function setSuccessUrl(string $successUrl): static - { - $this->successUrl = $successUrl; - - return $this; - } - - public function getFailureUrl(): ?string - { - return $this->failureUrl; - } - - public function setFailureUrl(string $failureUrl): static - { - $this->failureUrl = $failureUrl; - - return $this; - } -} diff --git a/src/Filter/VersionResourceFilter.php b/src/Filter/ResourceVersionResourceFilter.php similarity index 83% rename from src/Filter/VersionResourceFilter.php rename to src/Filter/ResourceVersionResourceFilter.php index fe7b33d..1c33eba 100644 --- a/src/Filter/VersionResourceFilter.php +++ b/src/Filter/ResourceVersionResourceFilter.php @@ -5,19 +5,21 @@ namespace App\Filter; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; +use App\Service\VersionedResourceService; 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 VersionResourceFilter extends AbstractFilter +final class ResourceVersionResourceFilter extends AbstractFilter { public function __construct( + private VersionedResourceService $versionedResourceService, protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, - protected ?NameConverterInterface $nameConverter = null + protected ?NameConverterInterface $nameConverter = null, ) { parent::__construct($managerRegistry, $logger, $properties, $nameConverter); } @@ -45,7 +47,7 @@ final class VersionResourceFilter extends AbstractFilter 'description' => 'The name of the resource.', 'schema' => [ 'type' => Type::BUILTIN_TYPE_STRING, - 'enum' => ['User'] + 'enum' => $this->versionedResourceService->getNames() ], 'openapi' => [ 'allowEmptyValue' => false, diff --git a/src/Filter/VersionResourceIdFilter.php b/src/Filter/ResourceVersionResourceIdFilter.php similarity index 94% rename from src/Filter/VersionResourceIdFilter.php rename to src/Filter/ResourceVersionResourceIdFilter.php index 0b68986..3f699a9 100644 --- a/src/Filter/VersionResourceIdFilter.php +++ b/src/Filter/ResourceVersionResourceIdFilter.php @@ -8,7 +8,7 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Symfony\Component\PropertyInfo\Type; -final class VersionResourceIdFilter extends AbstractFilter +final class ResourceVersionResourceIdFilter extends AbstractFilter { protected function filterProperty( string $property, diff --git a/src/Kernel.php b/src/Kernel.php index d8bf758..0388b2a 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -3,6 +3,7 @@ namespace App; use App\DependencyInjection\Compiler\GatewayPass; +use App\DependencyInjection\Compiler\VersionedResourcePass; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -18,5 +19,10 @@ class Kernel extends BaseKernel new GatewayPass, PassConfig::TYPE_AFTER_REMOVING ); + + $container->addCompilerPass( + new VersionedResourcePass, + PassConfig::TYPE_AFTER_REMOVING + ); } } diff --git a/src/Library/Benzina/Pump/CheckoutsPump.php b/src/Library/Benzina/Pump/CheckoutsPump.php new file mode 100644 index 0000000..e113865 --- /dev/null +++ b/src/Library/Benzina/Pump/CheckoutsPump.php @@ -0,0 +1,268 @@ +hasAllKeys($data[0], self::INVEST_KEYS); + } + + public function process(mixed $data): void + { + $users = $this->getUsers($data); + $projects = $this->getProjects($data); + + $tipjar = $this->getPlatformTipjar(); + + $pumped = $this->getPumped(GatewayCheckout::class, $data, ['migratedReference' => 'id']); + + foreach ($data as $key => $record) { + if ($this->isPumped($record, $pumped)) { + continue; + } + + if (!\array_key_exists($record['project'], $projects)) { + continue; + } + + if (!$record['amount'] || $record['amount'] < 1) { + continue; + } + + if (!$record['method'] || empty($record['method'])) { + continue; + } + + $user = $users[$record['user']]; + $project = $projects[$record['project']]; + + $checkout = new GatewayCheckout; + $checkout->setOrigin($user->getAccounting()); + $checkout->setStatus($this->getCheckoutStatus($record)); + $checkout->setGateway($this->getCheckoutGateway($record)); + $checkout->setGatewayReference($this->getCheckoutReference($record)); + $checkout->setCheckoutUrl(self::CHECKOUT_URL_DEFAULT); + $checkout->setMigrated(true); + $checkout->setMigratedReference($record['id']); + $checkout->setMetadata([ + 'payment' => $record['payment'], + 'transaction' => $record['transaction'], + 'preapproval' => $record['preapproval'] + ]); + $checkout->setCreatedAt(new \DateTime($record['invested'])); + $checkout->setUpdatedAt(new \DateTime()); + + $charge = new GatewayCharge; + $charge->setType($this->getChargeType($record)); + $charge->setMoney($this->getChargeMoney($record['amount'], $record['currency'])); + $charge->setTarget($project->getAccounting()); + + if ($record['donate_amount'] > 0) { + $tip = new GatewayCharge; + $tip->setType(GatewayChargeType::Single); + $tip->setMoney($this->getChargeMoney($record['donate_amount'], $record['currency'])); + $tip->setTarget($tipjar->getAccounting()); + + $this->entityManager->persist($tip); + $checkout->addCharge($tip); + } + + $this->entityManager->persist($charge); + $checkout->addCharge($charge); + + $this->entityManager->persist($checkout); + } + + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + private function getUsers(array $data): array + { + $users = $this->userRepository->findBy(['migratedReference' => \array_map(function ($data) { + return $data['user']; + }, $data)]); + + $usersByMigratedReference = []; + foreach ($users as $user) { + $usersByMigratedReference[$user->getMigratedReference()] = $user; + } + + return $usersByMigratedReference; + } + + private function getProjects(array $data): array + { + $projects = $this->projectRepository->findBy(['migratedReference' => \array_map(function ($data) { + return $data['project']; + }, $data)]); + + $projectsByMigratedReference = []; + foreach ($projects as $project) { + $projectsByMigratedReference[$project->getMigratedReference()] = $project; + } + + return $projectsByMigratedReference; + } + + 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); + + $accounting = new Accounting; + $accounting->setTipjar($tipjar); + + $this->entityManager->persist($tipjar); + $this->entityManager->persist($accounting); + $this->entityManager->flush(); + + return $tipjar; + } + + private function getChargeType(array $record): GatewayChargeType + { + if (\in_array($record['method'], ['stripe_subscription'])) { + return GatewayChargeType::Recurring; + } + + return GatewayChargeType::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): GatewayCheckoutStatus + { + if ($record['status'] < 1) { + return GatewayCheckoutStatus::Pending; + } + + return GatewayCheckoutStatus::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 ''; + } + } + + private function getCheckoutReference(array $record): string + { + if (!empty($record['payment'])) { + return $record['payment']; + } + + if (!empty($record['transaction'])) { + return $record['transaction']; + } + + if (!empty($record['preapproval'])) { + return $record['preapproval']; + } + + return ''; + } +} diff --git a/src/Library/Benzina/Pump/ProjectsPump.php b/src/Library/Benzina/Pump/ProjectsPump.php index f0c54b0..1a6e4cf 100644 --- a/src/Library/Benzina/Pump/ProjectsPump.php +++ b/src/Library/Benzina/Pump/ProjectsPump.php @@ -6,6 +6,8 @@ use App\Entity\Accounting; use App\Entity\Project; use App\Entity\ProjectStatus; use App\Entity\User; +use App\Library\Benzina\Pump\Trait\ArrayPumpTrait; +use App\Library\Benzina\Pump\Trait\ProgressivePumpTrait; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; @@ -131,6 +133,8 @@ class ProjectsPump implements PumpInterface $project->setStatus($this->getProjectStatus($record['status'])); $project->setMigrated(true); $project->setMigratedReference($record['id']); + $project->setCreatedAt(new \DateTime($record['created'])); + $project->setUpdatedAt(new \DateTime()); $accounting = $this->getAccounting($record); $accounting->setProject($project); diff --git a/src/Library/Benzina/Pump/ArrayPumpTrait.php b/src/Library/Benzina/Pump/Trait/ArrayPumpTrait.php similarity index 91% rename from src/Library/Benzina/Pump/ArrayPumpTrait.php rename to src/Library/Benzina/Pump/Trait/ArrayPumpTrait.php index 1903820..64d5c8b 100644 --- a/src/Library/Benzina/Pump/ArrayPumpTrait.php +++ b/src/Library/Benzina/Pump/Trait/ArrayPumpTrait.php @@ -1,6 +1,6 @@ setAccounting(new Accounting); $user->setUsername($this->getUsername($record)); $user->setPassword($record['password'] ?? ""); $user->setEmail($record['email']); diff --git a/src/Library/Economy/Currency/EuropeanCentralBankExchange.php b/src/Library/Economy/Currency/EuropeanCentralBankExchange.php index f51e298..9d2e2fc 100644 --- a/src/Library/Economy/Currency/EuropeanCentralBankExchange.php +++ b/src/Library/Economy/Currency/EuropeanCentralBankExchange.php @@ -25,7 +25,7 @@ class EuropeanCentralBankExchange implements ExchangeInterface public function __construct() { - $data = json_decode(json_encode(\simplexml_load_file(self::ECB_DATA)), true)['Cube']['Cube']; + $data = $this->getData(); $this->date = \DateTime::createFromFormat( \DateTimeInterface::RFC3339, @@ -33,7 +33,6 @@ class EuropeanCentralBankExchange implements ExchangeInterface new \DateTimeZone('CET') ); - $provider = new ConfigurableProvider(); foreach ($data['Cube'] as $item) { $rate = $item['@attributes']; @@ -44,6 +43,38 @@ class EuropeanCentralBankExchange implements ExchangeInterface $this->converter = new CurrencyConverter($this->provider); } + private function getDataLatest(): array + { + $data = \simplexml_load_file(self::ECB_DATA); + if (!$data) { + throw new \Exception("Could not retrieve XML data"); + } + + return \json_decode(\json_encode($data), true)['Cube']['Cube']; + } + + private function getDataCached(): array + { + $data = \apcu_fetch(self::ECB_DATA); + if (!$data) { + throw new \Exception("Could not retrieve cached data"); + } + + return \json_decode($data, true); + } + + public function getData(): array + { + try { + return $this->getDataCached(); + } catch (\Exception $e) { + $data = $this->getDataLatest(); + + \apcu_store(self::ECB_DATA, json_encode($data)); + return $this->getData(); + } + } + public function getName(): string { return 'european_central_bank'; @@ -71,7 +102,7 @@ class EuropeanCentralBankExchange implements ExchangeInterface public function getConversionRate(string $source, string $target): float { - return $this->provider->getExchangeRate($source, $target); + return $this->provider->getExchangeRate($source, $target)->toFloat(); } public function getConversionDate(string $source, string $target): \DateTimeInterface diff --git a/src/Library/Economy/Payment/CashGateway.php b/src/Library/Economy/Payment/CashGateway.php new file mode 100644 index 0000000..9b24ecb --- /dev/null +++ b/src/Library/Economy/Payment/CashGateway.php @@ -0,0 +1,11 @@ + + * + * @method AccountingStatement|null find($id, $lockMode = null, $lockVersion = null) + * @method AccountingStatement|null findOneBy(array $criteria, array $orderBy = null) + * @method AccountingStatement[] findAll() + * @method AccountingStatement[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class AccountingStatementRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AccountingStatement::class); + } + + // /** + // * @return AccountingStatement[] Returns an array of AccountingStatement objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?AccountingStatement + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/AccountingTransactionRepository.php b/src/Repository/AccountingTransactionRepository.php new file mode 100644 index 0000000..674c6a1 --- /dev/null +++ b/src/Repository/AccountingTransactionRepository.php @@ -0,0 +1,48 @@ + + * + * @method AccountingTransaction|null find($id, $lockMode = null, $lockVersion = null) + * @method AccountingTransaction|null findOneBy(array $criteria, array $orderBy = null) + * @method AccountingTransaction[] findAll() + * @method AccountingTransaction[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class AccountingTransactionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AccountingTransaction::class); + } + + // /** + // * @return AccountingTransaction[] Returns an array of AccountingTransaction objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('t') + // ->andWhere('t.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('t.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?AccountingTransaction + // { + // return $this->createQueryBuilder('t') + // ->andWhere('t.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/TransactionRepository.php b/src/Repository/TipjarRepository.php similarity index 59% rename from src/Repository/TransactionRepository.php rename to src/Repository/TipjarRepository.php index d77f9af..86fa53b 100644 --- a/src/Repository/TransactionRepository.php +++ b/src/Repository/TipjarRepository.php @@ -2,27 +2,27 @@ namespace App\Repository; -use App\Entity\Transaction; +use App\Entity\Tipjar; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends ServiceEntityRepository * - * @method Transaction|null find($id, $lockMode = null, $lockVersion = null) - * @method Transaction|null findOneBy(array $criteria, array $orderBy = null) - * @method Transaction[] findAll() - * @method Transaction[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * @method Tipjar|null find($id, $lockMode = null, $lockVersion = null) + * @method Tipjar|null findOneBy(array $criteria, array $orderBy = null) + * @method Tipjar[] findAll() + * @method Tipjar[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ -class TransactionRepository extends ServiceEntityRepository +class TipjarRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, Transaction::class); + parent::__construct($registry, Tipjar::class); } // /** - // * @return Transaction[] Returns an array of Transaction objects + // * @return Tipjar[] Returns an array of Tipjar objects // */ // public function findByExampleField($value): array // { @@ -36,7 +36,7 @@ class TransactionRepository extends ServiceEntityRepository // ; // } - // public function findOneBySomeField($value): ?Transaction + // public function findOneBySomeField($value): ?Tipjar // { // return $this->createQueryBuilder('t') // ->andWhere('t.exampleField = :val') diff --git a/src/Service/ApiResourceNormalizer.php b/src/Service/ApiResourceNormalizer.php index f8d958f..f615b99 100644 --- a/src/Service/ApiResourceNormalizer.php +++ b/src/Service/ApiResourceNormalizer.php @@ -14,7 +14,7 @@ class ApiResourceNormalizer { $classPieces = explode("\\", $className); - return strtolower(end($classPieces)); + return lcfirst(end($classPieces)); } /** @@ -22,6 +22,6 @@ class ApiResourceNormalizer */ public static function toEntity(string $resourceName): string { - return sprintf("App\\Entity\\%s", ucfirst(strtolower($resourceName))); + return sprintf("App\\Entity\\%s", ucfirst($resourceName)); } } diff --git a/src/Service/VersionedResourceService.php b/src/Service/VersionedResourceService.php new file mode 100644 index 0000000..9fe8ed1 --- /dev/null +++ b/src/Service/VersionedResourceService.php @@ -0,0 +1,65 @@ +makeCompileDir(); + + \file_put_contents( + self::getNamesLockFile(), + implode(PHP_EOL, $names) + ); + } + + /** + * @return array List of the versioned resource names + * @see compileNames() + */ + public static function getNames(): array + { + return explode(PHP_EOL, \file_get_contents(self::getNamesLockFile())); + } +} diff --git a/src/State/GatewayCheckoutProcessor.php b/src/State/GatewayCheckoutProcessor.php index 006c97a..d907d8f 100644 --- a/src/State/GatewayCheckoutProcessor.php +++ b/src/State/GatewayCheckoutProcessor.php @@ -26,8 +26,6 @@ class GatewayCheckoutProcessor implements ProcessorInterface $this->entityManager->persist($data); $this->entityManager->flush(); - $gateway->process($data); - - return $data; + return $gateway->create($data); } } diff --git a/src/State/GatewayCheckoutUpdateProcessor.php b/src/State/GatewayCheckoutUpdateProcessor.php new file mode 100644 index 0000000..3ed4605 --- /dev/null +++ b/src/State/GatewayCheckoutUpdateProcessor.php @@ -0,0 +1,40 @@ +checkoutRepository->find($uriVariables['id']); + + if (!$checkout) { + throw new HttpException(Response::HTTP_NOT_FOUND, sprintf("The checkout '%s' does not exist.", $uriVariables['id'])); + } + + $checkout->setGatewayReference($data->gatewayReference); + + $this->entityManager->persist($checkout); + $this->entityManager->flush(); + + return $checkout; + } +} diff --git a/src/State/VersionStateProvider.php b/src/State/ResourceVersionStateProvider.php similarity index 79% rename from src/State/VersionStateProvider.php rename to src/State/ResourceVersionStateProvider.php index cef0545..a14b3d6 100644 --- a/src/State/VersionStateProvider.php +++ b/src/State/ResourceVersionStateProvider.php @@ -8,11 +8,12 @@ use ApiPlatform\State\ProviderInterface; use App\ApiResource\Version; use App\Service\ApiResourceNormalizer; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\Mapping\MappingException; use Gedmo\Loggable\Entity\LogEntry; use Gedmo\Loggable\Entity\Repository\LogEntryRepository; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -class VersionStateProvider implements ProviderInterface +class ResourceVersionStateProvider implements ProviderInterface { private LogEntryRepository $versionRepository; @@ -58,7 +59,16 @@ class VersionStateProvider implements ProviderInterface { $resourceClass = ApiResourceNormalizer::toEntity($resourceName); - $entity = $this->entityManager->find($resourceClass, $resourceId); + try { + $entity = $this->entityManager->find($resourceClass, $resourceId); + } catch (MappingException $e) { + throw new NotFoundHttpException(sprintf("Resource '%s' does not exist", $resourceName)); + } + + if (!$entity) { + throw new NotFoundHttpException(sprintf("Resource '%s' with ID '%s' not found", $resourceName, $resourceId)); + } + $logs = $this->versionRepository->getLogEntries($entity); $versions = []; diff --git a/src/State/TransactionStateProcessor.php b/src/State/TransactionStateProcessor.php deleted file mode 100644 index 342d6c7..0000000 --- a/src/State/TransactionStateProcessor.php +++ /dev/null @@ -1,30 +0,0 @@ -gatewayLocator->getGatewayOf($data); - - $gateway->process($data); - - return $data; - } -} diff --git a/tests/Library/Economy/Currency/EuropeanCentralBankExchangeTest.php b/tests/Library/Economy/Currency/EuropeanCentralBankExchangeTest.php new file mode 100644 index 0000000..424adaf --- /dev/null +++ b/tests/Library/Economy/Currency/EuropeanCentralBankExchangeTest.php @@ -0,0 +1,35 @@ +getData(); + + $this->assertIsArray($exchangeData); + $this->assertArrayHasKey('Cube', $exchangeData); + $this->assertArrayHasKey('@attributes', $exchangeData); + } + + public function testStoresDataInApcuCache() + { + $exchange = new EuropeanCentralBankExchange; + // Data should be available on instantiation + + $data = \apcu_fetch($exchange::ECB_DATA); + + $this->assertNotFalse($data); + + $exchangeData = \json_decode($data, true); + + $this->assertIsArray($exchangeData); + $this->assertArrayHasKey('Cube', $exchangeData); + $this->assertArrayHasKey('@attributes', $exchangeData); + } +}