Merge pull request #6 from GoteoFoundation/feat/v3-invest-migration

v3 invest migration
This commit is contained in:
2024-05-27 10:58:31 +02:00
committed by GitHub
38 changed files with 1142 additions and 318 deletions
+7 -22
View File
@@ -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.
*/
@@ -0,0 +1,39 @@
<?php
namespace App\DependencyInjection\Compiler;
use App\Service\ApiResourceNormalizer;
use App\Service\VersionedResourceService;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class VersionedResourcePass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$allClasses = \get_declared_classes();
$entityClasses = \array_filter($allClasses, function (string $className) {
return \str_starts_with($className, 'App\\Entity\\');
});
$versionedResourceNames = [];
foreach ($entityClasses as $entityClass) {
$reflectionClass = new \ReflectionClass($entityClass);
$attributes = $reflectionClass->getAttributes(Gedmo\Loggable::class);
if (empty($attributes)) {
continue;
}
$versionedResourceNames = [
...$versionedResourceNames,
ApiResourceNormalizer::toResource($entityClass)
];
}
/** @var VersionedResourceService */
$versionedResourceService = $container->get(VersionedResourceService::class);
$versionedResourceService->compileNames($versionedResourceNames);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Dto;
use App\Entity\GatewayCheckoutStatus;
use Symfony\Component\Validator\Constraints as Assert;
final class GatewayCheckoutUpdateDto
{
/**
* The status of the checkout with the Gateway.
*/
#[Assert\NotBlank()]
public GatewayCheckoutStatus $status;
/**
* An external identifier provided by the Gateway for the payment.
*/
#[Assert\NotBlank()]
public string $gatewayReference;
public function __construct(
GatewayCheckoutStatus $status,
string $gatewayReference
) {
$this->status = $status;
$this->gatewayReference = $gatewayReference;
}
}
+37 -50
View File
@@ -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<int, Transaction>
* @return Collection<int, AccountingStatement>
*/
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<int, Transaction>
*/
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;
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\AccountingStatementRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* An AccountingStatement represents a change in the balance of an Accounting.\
* \
* When money is received, an Statement holds it as available.
* When money is issued, an Statement draws available money from previous statements.\
* \
* The sum of available money in the statements is the available money at that Accounting.
*/
#[API\GetCollection(
uriTemplate: '/accountings/{id}/statements',
uriVariables: [
'id' => 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;
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Entity;
enum AccountingStatementDirection: string
{
case Incoming = 'incoming';
case Outgoing = 'outgoing';
}
@@ -2,8 +2,7 @@
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\TransactionRepository;
use App\Repository\AccountingTransactionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -13,10 +12,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* When a Transaction targets an Accounting it means that the Accounting receives it, this will add to that Accounting.
* When a Transaction originates from an Accounting the Accounting issues the Transaction and it will deduct from it.
*/
#[ORM\Entity(repositoryClass: TransactionRepository::class)]
#[API\GetCollection()]
#[API\Get()]
class Transaction
#[ORM\Entity(repositoryClass: AccountingTransactionRepository::class)]
class AccountingTransaction
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -47,20 +44,6 @@ class Transaction
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $target = null;
/**
* The name of the Gateway implementation that secured the money in this Transaction.
*/
#[Assert\NotBlank()]
#[ORM\Column(length: 255)]
private ?string $gateway = null;
/**
* An external identifier provided by the Gateway for this Transaction.
*/
#[Assert\NotBlank()]
#[ORM\Column(length: 255)]
private ?string $gatewayReference = null;
public function getId(): ?int
{
return $this->id;
@@ -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;
}
}
+25 -40
View File
@@ -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;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Entity;
enum GatewayChargeType: string
{
/**
* A one-time charge to the money payer.
*/
case Single = 'single';
/**
* A recurring charge to the money payer.
*/
case Recurring = 'recurring';
}
+156 -35
View File
@@ -2,62 +2,117 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata as API;
use App\Dto\GatewayCheckoutUpdateDto;
use App\Repository\GatewayCheckoutRepository;
use App\State\GatewayCheckoutProcessor;
use App\State\GatewayCheckoutUpdateProcessor;
use App\Validator\GatewayName;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A GatewayCheckout bundles the data to perform a charge operation at a Gateway.
* Use it in order to create Transactions and have the transferred money be backed by a Gateway's payment processing.
*/
#[Gedmo\Loggable()]
#[API\GetCollection()]
#[API\Post(processor: GatewayCheckoutProcessor::class)]
#[API\Post(processor: GatewayCheckoutProcessor::class, validationContext: ['groups' => ['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;
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Entity;
enum GatewayCheckoutStatus: string
{
case Pending = 'pending';
case Charged = 'charged';
}
+1 -1
View File
@@ -104,7 +104,7 @@ class Project
return $this->owner;
}
public function setOwner(User $owner): static
public function setOwner(?User $owner): static
{
$this->owner = $owner;
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\TipjarRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* Unlike other Money recipients a Tipjar receives money with no further goal.\
* \
* Tips to the platform owners and other no-purpose money can target a Tipjar.
*/
#[UniqueEntity(fields: ['name'], message: 'A Tipjar with that name already exists.')]
#[API\ApiResource]
#[ORM\Entity(repositoryClass: TipjarRepository::class)]
class Tipjar
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* Human readable, non white space, unique string.
*/
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\OneToOne(inversedBy: 'tipjar', cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $accounting = null;
public function getId(): ?int
{
return $this->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;
}
}
-68
View File
@@ -1,68 +0,0 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Validator\GatewayName;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* GatewayCheckouts represent an User's attempt to perform a payment with an specific Gateway.
*/
#[ORM\Embeddable]
class TransactionGateway
{
/**
* The Gateway processing this Transaction.
*/
#[GatewayName]
#[Assert\NotBlank()]
#[ORM\Column(length: 255)]
private ?string $name = null;
#[Assert\NotBlank()]
#[Assert\Url()]
#[ORM\Column(length: 255)]
private ?string $successUrl;
#[Assert\Url()]
#[ORM\Column(length: 255)]
private ?string $failureUrl;
public function getName(): ?string
{
return $this->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;
}
}
@@ -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,
@@ -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,
+6
View File
@@ -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
);
}
}
+268
View File
@@ -0,0 +1,268 @@
<?php
namespace App\Library\Benzina\Pump;
use App\Entity\Accounting;
use App\Entity\GatewayCharge;
use App\Entity\GatewayChargeType;
use App\Entity\GatewayCheckout;
use App\Entity\GatewayCheckoutStatus;
use App\Entity\Money;
use App\Entity\Tipjar;
use App\Library\Benzina\Pump\Trait\ArrayPumpTrait;
use App\Library\Benzina\Pump\Trait\ProgressivePumpTrait;
use App\Library\Economy\Payment\CashGateway;
use App\Library\Economy\Payment\CecaGateway;
use App\Library\Economy\Payment\DropGateway;
use App\Library\Economy\Payment\PaypalGateway;
use App\Library\Economy\Payment\StripeGateway;
use App\Library\Economy\Payment\WalletGateway;
use App\Repository\ProjectRepository;
use App\Repository\TipjarRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
class CheckoutsPump implements PumpInterface
{
use ArrayPumpTrait;
use ProgressivePumpTrait;
private const PLATFORM_TIPJAR_NAME = 'platform';
private const CHECKOUT_URL_DEFAULT = 'https://www.goteo.org/invest';
private const MAX_INT = 2147483647;
private const INVEST_KEYS = [
'id',
'user',
'project',
'account',
'amount',
'amount_original',
'currency',
'currency_rate',
'donate_amount',
'status',
'anonymous',
'resign',
'invested',
'charged',
'returned',
'preapproval',
'payment',
'transaction',
'method',
'admin',
'campaign',
'datetime',
'drops',
'droped',
'call',
'matcher',
'issue',
'pool',
'extra_info'
];
public function __construct(
private UserRepository $userRepository,
private ProjectRepository $projectRepository,
private TipjarRepository $tipjarRepository,
private EntityManagerInterface $entityManager
) {
}
public function supports(mixed $data): bool
{
if (!\is_array($data) || !\array_key_exists(0, $data)) {
return false;
}
return $this->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 '';
}
}
@@ -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);
@@ -1,6 +1,6 @@
<?php
namespace App\Library\Benzina\Pump;
namespace App\Library\Benzina\Pump\Trait;
trait ArrayPumpTrait
{
@@ -1,6 +1,6 @@
<?php
namespace App\Library\Benzina\Pump;
namespace App\Library\Benzina\Pump\Trait;
use Doctrine\ORM\EntityManagerInterface;
+3
View File
@@ -4,6 +4,8 @@ namespace App\Library\Benzina\Pump;
use App\Entity\Accounting;
use App\Entity\User;
use App\Library\Benzina\Pump\Trait\ArrayPumpTrait;
use App\Library\Benzina\Pump\Trait\ProgressivePumpTrait;
use Doctrine\ORM\EntityManagerInterface;
class UsersPump implements PumpInterface
@@ -72,6 +74,7 @@ class UsersPump implements PumpInterface
}
$user = new User;
$user->setAccounting(new Accounting);
$user->setUsername($this->getUsername($record));
$user->setPassword($record['password'] ?? "");
$user->setEmail($record['email']);
@@ -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
@@ -0,0 +1,11 @@
<?php
namespace App\Library\Economy\Payment;
abstract class CashGateway implements GatewayInterface
{
public static function getName(): string
{
return 'cash';
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Library\Economy\Payment;
abstract class CecaGateway implements GatewayInterface
{
public static function getName(): string
{
return 'tpv';
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Library\Economy\Payment;
abstract class DropGateway implements GatewayInterface
{
public static function getName(): string
{
return 'drop';
}
}
@@ -12,11 +12,11 @@ interface GatewayInterface
public static function getName(): string;
/**
* When processing a GatewayCheckout a Gateway must validate it using it's own means.
* Connects with the payment gateway and creates a checkout session to process payment.
*
* Gateways are trusted to have secured the funds in the GatewayCheckout.
* @param GatewayCheckout $checkout
* @return GatewayCheckout
*/
public function process(GatewayCheckout $checkout): GatewayCheckout;
public function create(GatewayCheckout $checkout): GatewayCheckout;
}
@@ -0,0 +1,11 @@
<?php
namespace App\Library\Economy\Payment;
abstract class PaypalGateway implements GatewayInterface
{
public static function getName(): string
{
return 'paypal';
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\AccountingStatement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccountingStatement>
*
* @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()
// ;
// }
}
@@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\AccountingTransaction;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccountingTransaction>
*
* @method AccountingTransaction|null find($id, $lockMode = null, $lockVersion = null)
* @method AccountingTransaction|null findOneBy(array $criteria, array $orderBy = null)
* @method AccountingTransaction[] findAll()
* @method AccountingTransaction[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class AccountingTransactionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccountingTransaction::class);
}
// /**
// * @return AccountingTransaction[] Returns an array of AccountingTransaction objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('t')
// ->andWhere('t.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('t.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?AccountingTransaction
// {
// return $this->createQueryBuilder('t')
// ->andWhere('t.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -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<Transaction>
* @extends ServiceEntityRepository<Tipjar>
*
* @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')
+2 -2
View File
@@ -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));
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Service;
class VersionedResourceService
{
public const VERSIONED_RESOURCES_DIR = 'versioned_resources';
public const VERSIONED_RESOURCE_NAMES_LOCK = 'versioned_resource_names_compiled.lock';
private static function getCompileDir(): string
{
return sprintf(
'%s%svar%s%s',
\dirname(__DIR__, 2),
DIRECTORY_SEPARATOR,
DIRECTORY_SEPARATOR,
self::VERSIONED_RESOURCES_DIR,
);
}
private static function getNamesLockFile(): string
{
return sprintf(
'%s%s%s',
self::getCompileDir(),
DIRECTORY_SEPARATOR,
self::VERSIONED_RESOURCE_NAMES_LOCK
);
}
/**
* Generates a directory for the service files.
*/
public function makeCompileDir()
{
$compileDir = self::getCompileDir();
if (!\is_dir($compileDir)) {
\mkdir($compileDir, 0777, true);
}
}
/**
* Stores the given names in lock file.
* @param string[] $names
*/
public function compileNames(array $names)
{
$this->makeCompileDir();
\file_put_contents(
self::getNamesLockFile(),
implode(PHP_EOL, $names)
);
}
/**
* @return array<string> List of the versioned resource names
* @see compileNames()
*/
public static function getNames(): array
{
return explode(PHP_EOL, \file_get_contents(self::getNamesLockFile()));
}
}
+1 -3
View File
@@ -26,8 +26,6 @@ class GatewayCheckoutProcessor implements ProcessorInterface
$this->entityManager->persist($data);
$this->entityManager->flush();
$gateway->process($data);
return $data;
return $gateway->create($data);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Dto\GatewayCheckoutUpdateDto;
use App\Entity\GatewayCheckout;
use App\Repository\GatewayCheckoutRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class GatewayCheckoutUpdateProcessor implements ProcessorInterface
{
public function __construct(
private GatewayCheckoutRepository $checkoutRepository,
private EntityManagerInterface $entityManager,
) {
}
/**
* @param GatewayCheckoutUpdateDto $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GatewayCheckout
{
$checkout = $this->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;
}
}
@@ -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 = [];
-30
View File
@@ -1,30 +0,0 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Transaction;
use App\Library\Economy\Payment\GatewayLocator;
use Doctrine\ORM\EntityManagerInterface;
class TransactionStateProcessor implements ProcessorInterface
{
public function __construct(
private GatewayLocator $gatewayLocator,
private EntityManagerInterface $entityManager
) {
}
/**
* @param Transaction $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Transaction
{
$gateway = $this->gatewayLocator->getGatewayOf($data);
$gateway->process($data);
return $data;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Tests\Library\Economy\Currency;
use App\Library\Economy\Currency\EuropeanCentralBankExchange;
use PHPUnit\Framework\TestCase;
class EuropeanCentralBankExchangeTest extends TestCase
{
public function testGetsData()
{
$exchange = new EuropeanCentralBankExchange;
$exchangeData = $exchange->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);
}
}