x-one / przelewy24-bundle
Integrates Przelewy24 payment API with Symfony applications
Requires
- php: >=8.2
- doctrine/persistence: ^3
- mnastalski/przelewy24-php: ^1.1
- symfony/framework-bundle: ^6.2
- symfony/routing: ^6.0
- symfony/serializer: ^6.0
- symfony/translation: ^6.0
- symfony/uid: ^6.2
- symfony/validator: ^6.2
This package is not auto-updated.
Last update: 2024-12-11 18:34:08 UTC
README
Przelewy24Bundle integrates Symfony applications with Przelewy24 payment API, widely used in Poland.
Installation
See the English tutorial, or read the Polish translation.
Example usage
The Transaction
entity from Installation docs is too simple for real world use. In a typical use-case, we will want to create a relation between a Transaction
and what it's associated with.
Let's assume that we want to pay for Order
entities. We must start with adding a relation to the Transaction
entity:
#[ORM\Entity]
class Transaction extends Przelewy24Transaction
{
#[ORM\OneToOne(inversedBy: 'transaction')]
#[ORM\JoinColumn(nullable: false)]
private ?Order $order = null;
// ... Getters and setters
}
Create a database migration and run it. Now it's possible to handle the payment process for an Order. We need to create a Transaction entity:
class OrderTransactionFactory
{
public function __construct(
// You can either set the redirect URL in services.yaml,
// when your frontend is decoupled (SPA) from the backend
// Or you can instead inject UrlGenerator
// and point to an internal route
private string $afterPaymentRedirectUrl,
) {}
/** Creates a transaction for an order. */
public function createTransaction(Order $order): Transaction
{
// Let's assume Order stores prices in PLN in a decimal format: 12.34 PLN
// We need to convert PLN to 1/100 of PLN (grosz),
// as it's the lowest common denominator
$transactionAmount = (int) bcmul($order->getTotalGrossPrice(), '100', 2);
$transaction = new Transaction();
$transaction
->setOrder($order)
->setEmail($order->getEmail())
->setAmount($transactionAmount)
->setUrlReturn($this->afterPaymentRedirectUrl)
->setDescription('Order ' . $order->getId())
;
return $transaction;
}
}
We can now use OrderTransactionFactory.createTransaction
to create a Transaction for a given Order. We still need to submit it to the Przelewy24 API:
use XOne\Bundle\Przelewy24Bundle\Service\Przelewy24Client;
use Doctrine\ORM\EntityManagerInterface;
class OrderPaymentService
{
public function __construct(
private EntityManagerInterface $entityManager,
private OrderTransactionFactory $transactionFactory,
private Przelewy24Client $przelewy24Client,
) {
}
public function beginPayment(Order $order): Transaction
{
// <Business logic - validate the entity, ...>
// Create a Transaction
$transaction = $this->transactionFactory->createTransaction($divorce);
// Save Transaction in the database prior to calling Przelewy24 API
// This ensures that no race conditions can occur
$this->entityManager->persist($transaction);
$this->entityManager->flush();
// Call Przelewy24 API - generates a payment link for the user
$this->przelewy24Client->submitTransaction($transaction);
$this->entityManager->flush();
return $transaction;
}
Side-note: It's beneficial to use locks. It protects business logic related to the payment process from race conditions.
$store = new FlockStore();
$factory = new LockFactory($store);
$lock = $factory->createLock('order-payment-'.$order->getId(), ttl: 5);
if (!$lock->acquire()) {
throw new \Error('The Order payment process is locked!');
}
try {
return $this->orderPaymentService->beginPayment($order);
} finally {
$lock->release();
}
We can now use OrderPaymentService.beginPayment
to create an Transaction
for an Order
and submit it to Przelewy24 API. The results are stored in the Transaction entity, which is persisted in the database.
We can now use the resulting Transaction
in a controller:
public function __invoke(Order $order): JsonResponse
{
$transaction = $this->orderPaymentService->beginPayment($order);
// Process $transaction->status to ensure it's successful
return new JsonResponse([
'gatewayUrl' => $transaction->getUrlGateway(),
]);
}
The user now can successfully start the payment process. On success, they receive a payment gateway URL, and they can pay for their Order
.
Once a customer successfully pays, the Przelewy24 API calls a webhook in our application. This is handled by the bundle and the transaction is marked as paid.
Let's process the Order
entity on successful payment. We must create a Message Handler:
use App\Entity\Transaction;
use XOne\Bundle\Przelewy24Bundle\Messenger\Przelewy24TransactionPaid;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class TransactionPaidHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
}
public function __invoke(Przelewy24TransactionPaid $message): void
{
// Read the Transaction entity
$repository = $this->entityManager->getRepository($message->transactionFQCN);
/** @var Transaction $transaction */
$transaction = $repository->find($message->transactionId);
$order = $transaction->getOrder();
if (!is_null($order)) {
// Do something with the Order
// Send emails, mark it for realization, etc...
} else {
throw new \Error('Payment for a Transaction was processed, but there were no relations!');
}
}
}
Now the payment process is fully handled. An user can pay for their Order, and a successful payment will be processed.
If you introduce additional relations to the Transaction
entity, it is be possible to handle them in any way you want in the TransactionPaidHandler
. For example, it's possible to introduce multiple 1-1
relations with different Entities, and handle the payment process with separate business logic.
Roadmap
- Internal refactor to improve code readability and testability
- Extend the Transaction entity to support more fields from Przelewy24 API, such as currency, language, etc.
- Write unit and integration tests