Skip to content

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)
);