Webshop - Payments (Pay.nl)¶
Overview¶
Payment processing is handled via Pay.nl, supporting various payment methods including Bancontact, iDEAL, credit cards, and B2B on-account payments.
Payment Methods¶
Online Payment Methods (via Pay.nl)¶
| Method | Countries | Description |
|---|---|---|
| Bancontact | BE | Belgian debit card |
| iDEAL | NL | Dutch bank transfer |
| Credit Card | All | Visa, Mastercard |
| PayPal | All | PayPal account |
| Bank Transfer | All | Manual bank transfer |
B2B Payment Methods¶
| Method | Description |
|---|---|
| On Account | Invoice with payment terms (30 days, etc.) |
Pay.nl Client Library¶
// atraxion/paynl-client library
namespace Atraxion\PayNL;
class PayNLClient
{
public function __construct(
private string $apiToken,
private string $serviceId,
private HttpClientInterface $httpClient,
) {}
public function createTransaction(CreateTransactionRequest $request): Transaction
{
$response = $this->httpClient->request('POST', 'https://rest-api.pay.nl/v1/Transaction/start', [
'json' => [
'serviceId' => $this->serviceId,
'amount' => $request->amountInCents,
'ipAddress' => $request->ipAddress,
'returnUrl' => $request->returnUrl,
'exchangeUrl' => $request->webhookUrl,
'paymentOptionId' => $request->paymentMethodId,
'transaction' => [
'description' => $request->description,
'orderNumber' => $request->orderNumber,
],
'enduser' => [
'emailAddress' => $request->customerEmail,
],
],
'headers' => [
'Authorization' => 'Basic ' . base64_encode($this->apiToken . ':'),
],
]);
$data = $response->toArray();
return new Transaction(
transactionId: $data['transaction']['transactionId'],
paymentUrl: $data['transaction']['paymentURL'],
status: TransactionStatus::from($data['transaction']['state']),
);
}
public function getTransaction(string $transactionId): Transaction
{
$response = $this->httpClient->request('GET',
"https://rest-api.pay.nl/v1/Transaction/info/transactionId/{$transactionId}", [
'headers' => [
'Authorization' => 'Basic ' . base64_encode($this->apiToken . ':'),
],
]);
$data = $response->toArray();
return new Transaction(
transactionId: $data['paymentDetails']['transactionId'],
paymentUrl: null,
status: TransactionStatus::from($data['paymentDetails']['state']),
paidAt: isset($data['paymentDetails']['paidAt'])
? new \DateTimeImmutable($data['paymentDetails']['paidAt'])
: null,
);
}
public function refund(string $transactionId, int $amountInCents, string $description): RefundResult
{
$response = $this->httpClient->request('POST', 'https://rest-api.pay.nl/v1/Transaction/refund', [
'json' => [
'transactionId' => $transactionId,
'amount' => $amountInCents,
'description' => $description,
],
'headers' => [
'Authorization' => 'Basic ' . base64_encode($this->apiToken . ':'),
],
]);
$data = $response->toArray();
return new RefundResult(
success: $data['request']['result'] === '1',
refundId: $data['refundId'] ?? null,
);
}
}
class CreateTransactionRequest
{
public function __construct(
public int $amountInCents,
public string $orderNumber,
public string $description,
public string $customerEmail,
public string $ipAddress,
public string $returnUrl,
public string $webhookUrl,
public int $paymentMethodId,
) {}
}
enum TransactionStatus: string
{
case PENDING = 'PENDING';
case PAID = 'PAID';
case CANCELLED = 'CANCELLED';
case FAILED = 'FAILED';
case REFUNDED = 'REFUNDED';
case PARTIALLY_REFUNDED = 'PARTIALLY_REFUNDED';
}
Payment Service¶
class PaymentService
{
public function __construct(
private PayNLClient $payNLClient,
private PaymentMethodRepository $paymentMethodRepository,
private UrlGeneratorInterface $urlGenerator,
) {}
public function getAvailableMethods(Customer $customer): array
{
$methods = $this->paymentMethodRepository->findActive();
// Filter based on customer type
if ($customer->hasPaymentTerms()) {
// B2B with terms can pay on account
$methods[] = new PaymentMethod(
id: 'on_account',
name: 'On Account',
description: sprintf('Pay within %d days', $customer->getPaymentTermsDays()),
);
}
return $methods;
}
public function requiresRedirect(string $methodId): bool
{
return $methodId !== 'on_account';
}
public function initiatePayment(Order $order, string $methodId): string
{
if ($methodId === 'on_account') {
$this->processOnAccountPayment($order);
return $this->urlGenerator->generate('checkout_success', [
'orderId' => $order->getId(),
]);
}
$paymentMethod = $this->paymentMethodRepository->find($methodId);
$request = new CreateTransactionRequest(
amountInCents: (int) bcmul($order->getGrandTotal(), '100'),
orderNumber: $order->getOrderNumber(),
description: sprintf('Order %s', $order->getOrderNumber()),
customerEmail: $order->getCustomerData()->getEmail(),
ipAddress: $this->requestStack->getCurrentRequest()->getClientIp(),
returnUrl: $this->urlGenerator->generate('payment_return', [
'orderNumber' => $order->getOrderNumber(),
], UrlGeneratorInterface::ABSOLUTE_URL),
webhookUrl: $this->urlGenerator->generate('payment_webhook', [],
UrlGeneratorInterface::ABSOLUTE_URL),
paymentMethodId: $paymentMethod->getPayNLId(),
);
$transaction = $this->payNLClient->createTransaction($request);
// Store transaction ID
$order->setPaymentTransactionId($transaction->transactionId);
$order->setPaymentMethod($methodId);
$order->setPaymentStatus('processing');
$this->entityManager->flush();
return $transaction->paymentUrl;
}
private function processOnAccountPayment(Order $order): void
{
$order->setPaymentMethod('on_account');
$order->setPaymentStatus('on_account');
$order->setStatus('confirmed');
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new OrderPaidEvent($order));
}
}
Payment Callbacks¶
Return URL Handler¶
Called when customer returns from Pay.nl (regardless of payment result).
class PaymentReturnController
{
#[Route('/payment/return/{orderNumber}', name: 'payment_return')]
public function return(string $orderNumber, Request $request): Response
{
// IMPORTANT: Don't rely on session here - it may have expired
// Always look up order by transaction ID
$order = $this->orderRepository->findByOrderNumber($orderNumber);
if (!$order) {
throw $this->createNotFoundException('Order not found');
}
// Check payment status with Pay.nl
$transaction = $this->payNLClient->getTransaction($order->getPaymentTransactionId());
return match ($transaction->status) {
TransactionStatus::PAID => $this->handlePaidReturn($order),
TransactionStatus::PENDING => $this->handlePendingReturn($order),
TransactionStatus::CANCELLED => $this->handleCancelledReturn($order),
TransactionStatus::FAILED => $this->handleFailedReturn($order),
default => $this->handleUnknownReturn($order),
};
}
private function handlePaidReturn(Order $order): Response
{
// Payment confirmed, show success page
// Note: actual status update should happen via webhook for reliability
return $this->render('checkout/success.html.twig', [
'order' => $order,
]);
}
private function handlePendingReturn(Order $order): Response
{
// Payment still processing
return $this->render('checkout/pending.html.twig', [
'order' => $order,
]);
}
private function handleCancelledReturn(Order $order): Response
{
// Customer cancelled payment
return $this->render('checkout/cancelled.html.twig', [
'order' => $order,
'canRetry' => true,
]);
}
private function handleFailedReturn(Order $order): Response
{
// Payment failed
return $this->render('checkout/failed.html.twig', [
'order' => $order,
'canRetry' => true,
]);
}
}
Webhook Handler¶
Called by Pay.nl when payment status changes (server-to-server).
class PaymentWebhookController
{
#[Route('/payment/webhook', name: 'payment_webhook', methods: ['POST'])]
public function webhook(Request $request): Response
{
$transactionId = $request->request->get('order_id');
if (!$transactionId) {
return new Response('Missing transaction ID', 400);
}
// Get transaction details from Pay.nl
$transaction = $this->payNLClient->getTransaction($transactionId);
// Find order by transaction ID
$order = $this->orderRepository->findByPaymentTransactionId($transactionId);
if (!$order) {
$this->logger->error('Order not found for transaction', [
'transactionId' => $transactionId,
]);
return new Response('TRUE'); // Return TRUE to stop retries
}
// Process status change
$this->processPaymentStatusChange($order, $transaction);
return new Response('TRUE');
}
private function processPaymentStatusChange(Order $order, Transaction $transaction): void
{
// Prevent duplicate processing
if ($order->getPaymentStatus() === 'paid' && $transaction->status === TransactionStatus::PAID) {
return;
}
match ($transaction->status) {
TransactionStatus::PAID => $this->handlePaid($order, $transaction),
TransactionStatus::CANCELLED => $this->handleCancelled($order),
TransactionStatus::FAILED => $this->handleFailed($order),
TransactionStatus::REFUNDED => $this->handleRefunded($order),
default => null,
};
}
private function handlePaid(Order $order, Transaction $transaction): void
{
$order->setPaymentStatus('paid');
$order->setPaidAt($transaction->paidAt ?? new \DateTimeImmutable());
$order->setStatus('confirmed');
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new OrderPaidEvent($order));
}
private function handleCancelled(Order $order): void
{
$order->setPaymentStatus('failed');
// Only cancel order if it wasn't already confirmed
if ($order->getStatus() === 'pending_payment') {
$order->setStatus('cancelled');
}
$this->entityManager->flush();
}
private function handleFailed(Order $order): void
{
$order->setPaymentStatus('failed');
$this->entityManager->flush();
}
private function handleRefunded(Order $order): void
{
$order->setPaymentStatus('refunded');
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new OrderRefundedEvent($order));
}
}
Retry Payment¶
Allow customers to retry failed payments.
class RetryPaymentController
{
#[Route('/payment/retry/{orderNumber}', name: 'payment_retry')]
public function retry(string $orderNumber): Response
{
$customer = $this->security->getUser()->getCustomer();
$order = $this->orderRepository->findOneBy([
'orderNumber' => $orderNumber,
'customer' => $customer,
]);
if (!$order || !$this->canRetryPayment($order)) {
throw $this->createAccessDeniedException();
}
// Show payment method selection
return $this->render('checkout/retry_payment.html.twig', [
'order' => $order,
'paymentMethods' => $this->paymentService->getAvailableMethods($customer),
]);
}
#[Route('/payment/retry/{orderNumber}/process', name: 'payment_retry_process', methods: ['POST'])]
public function process(string $orderNumber, Request $request): Response
{
$customer = $this->security->getUser()->getCustomer();
$order = $this->orderRepository->findOneBy([
'orderNumber' => $orderNumber,
'customer' => $customer,
]);
if (!$order || !$this->canRetryPayment($order)) {
throw $this->createAccessDeniedException();
}
$methodId = $request->request->get('payment_method');
$redirectUrl = $this->paymentService->initiatePayment($order, $methodId);
return $this->redirect($redirectUrl);
}
private function canRetryPayment(Order $order): bool
{
return in_array($order->getPaymentStatus(), ['pending', 'failed'])
&& in_array($order->getStatus(), ['pending', 'pending_payment']);
}
}
Session Expiry Handling¶
Important: Payment gateway redirects can take a long time (3D Secure, bank authentication, etc.). The PHP session may expire before the customer returns.
class PaymentReturnController
{
#[Route('/payment/return/{orderNumber}', name: 'payment_return')]
public function return(string $orderNumber): Response
{
// NEVER rely on $this->getUser() here - session may have expired!
// Always look up order by order number from URL
$order = $this->orderRepository->findByOrderNumber($orderNumber);
if (!$order) {
throw $this->createNotFoundException();
}
// Get customer from order, not from session
$customer = $order->getCustomer();
// Clear cart based on order's customer
$this->cartService->clearByCustomer($customer);
// Render with data from order, not session
return $this->render('checkout/success.html.twig', [
'order' => $order,
'customer' => $customer,
]);
}
}
Gherkin Scenarios¶
Feature: Payment Processing
As a customer
I want to pay for my order
So that I can receive my products
Scenario: Successful Bancontact payment
Given I have completed checkout
And I selected "Bancontact" as payment method
When I am redirected to Pay.nl
And I complete the Bancontact payment
Then I should be redirected to the success page
And my order status should be "confirmed"
And payment status should be "paid"
Scenario: Cancelled payment
Given I have completed checkout
And I am on the Pay.nl payment page
When I cancel the payment
Then I should be redirected to the cancelled page
And I should be able to retry payment
Scenario: Failed payment
Given I have completed checkout
And I am on the Pay.nl payment page
When the payment fails
Then I should be redirected to the failed page
And I should be able to retry with a different method
Scenario: Payment webhook updates order
Given an order with pending payment
When Pay.nl sends a webhook with status "PAID"
Then the order payment status should be "paid"
And the order status should be "confirmed"
And a payment confirmation email should be sent
Scenario: B2B on-account payment
Given I am a B2B customer with 30-day payment terms
And I have completed checkout
When I select "On Account" as payment method
Then my order should be created immediately
And payment status should be "on_account"
And I should see the order confirmation
Scenario: Retry failed payment
Given I have an order with failed payment
When I click "Retry Payment"
And I select a different payment method
Then I should be redirected to Pay.nl
And I should be able to complete the payment
Scenario: Session expired during payment
Given I completed checkout and was redirected to Pay.nl
And my session has expired while I was paying
When I complete the payment and return
Then I should still see the success page
And my order should be properly processed
Database Schema¶
-- Payment methods
CREATE TABLE payment_methods (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
paynl_id INT,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0
);
-- Payment transactions log
CREATE TABLE payment_transactions (
id INT PRIMARY KEY AUTO_INCREMENT,
order_id INT NOT NULL,
transaction_id VARCHAR(100) NOT NULL,
payment_method VARCHAR(50) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
paynl_response JSON,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id),
INDEX idx_transaction_id (transaction_id)
);
-- Refunds
CREATE TABLE payment_refunds (
id INT PRIMARY KEY AUTO_INCREMENT,
order_id INT NOT NULL,
transaction_id VARCHAR(100) NOT NULL,
refund_id VARCHAR(100),
amount DECIMAL(10,2) NOT NULL,
reason TEXT,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
created_by INT,
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);