bpotmalnik/lunar-paynow

PayNow payment driver for LunarPHP

Maintainers

Package info

github.com/bpotmalnik/lunar-paynow

pkg:composer/bpotmalnik/lunar-paynow

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-05-29 12:10 UTC

This package is auto-updated.

Last update: 2026-05-30 15:05:58 UTC


README

Latest Stable Version PHP Version License

PayNow (docs) v3 payment driver for LunarPHP. Handles the full payment lifecycle — authorization, notifications, refunds, partial refunds, refund cancellation, and payment recovery — following Lunar's payment driver conventions.

Requirements

  • PHP 8.3+
  • Laravel 12 or 13
  • LunarPHP 1.x

Installation

Install the package via Composer:

composer require bpotmalnik/lunar-paynow

Publish the configuration file and migrations, then run the migrations:

php artisan vendor:publish --tag=lunar-paynow-config
php artisan vendor:publish --tag=lunar-paynow-migrations
php artisan migrate

Optionally publish the translation files if you want to customise the error messages:

php artisan vendor:publish --tag=lunar-paynow-lang

Configuration

Add the following variables to your .env file:

PAYNOW_API_KEY=your-api-key
PAYNOW_SIGNATURE_KEY=your-signature-key
PAYNOW_SANDBOX=true

Both credentials are available in your PayNow merchant dashboard under IntegrationKeys.

Notification URL

PayNow sends server-to-server POST requests when a payment status changes. Configure the full URL in your merchant dashboard under PoS settingsNotification URL:

https://yoursite.com/paynow/notification

The route is registered automatically by the package. It operates outside the web middleware group — CSRF protection is replaced by HMAC-SHA256 signature verification on every inbound request.

You may change the path via the environment:

PAYNOW_NOTIFICATION_PATH=paynow/notification

Order status mapping

By default the package maps PayNow statuses to these Lunar order statuses. Override them to match your store's configuration:

PAYNOW_STATUS_CONFIRMED=payment-received
PAYNOW_STATUS_REJECTED=payment-failed
PAYNOW_STATUS_ABANDONED=payment-failed
PAYNOW_STATUS_EXPIRED=payment-failed
PAYNOW_STATUS_ERROR=payment-failed

Full configuration reference: config/lunar/paynow.php.

Usage

Authorizing a payment

Call authorize() from your checkout controller. The method creates a draft order from the cart (or reuses one if it already exists), calls the PayNow API, and returns a redirect URL.

use Lunar\Facades\Payments;

$result = Payments::driver('paynow')
    ->cart($cart)
    ->withData([
        'continue_url' => route('checkout.complete'),
    ])
    ->authorize();

if (! $result->success) {
    return back()->withErrors(['payment' => $result->message]);
}

return redirect($result->redirectUrl);

$result->message is always safe to display to the customer. For the detailed admin-level reason (e.g. "Signature key misconfigured"), use $result->adminMessage() in logs or the admin panel.

Optional withData keys

Key Description
continue_url Where PayNow redirects the customer after the payment page. Overrides the PoS default.
description Payment description shown on the PayNow page. Falls back to PAYNOW_PAYMENT_DESCRIPTION.
refund_reason One of RMA, REFUND_BEFORE_14, REFUND_AFTER_14, OTHER. Used when calling refund().

Handling the redirect

After authorize() succeeds, redirect the customer to $result->redirectUrl. PayNow handles the payment and redirects them back to your continue_url. At this point the payment may not yet be confirmed — confirmation arrives asynchronously via the notification endpoint.

A typical continue_url handler simply polls the order status:

public function complete(Order $order)
{
    if ($order->placed_at) {
        return view('checkout.success', ['order' => $order]);
    }

    // Payment still pending — show a waiting page or poll via JS.
    return view('checkout.pending', ['order' => $order]);
}

Payment recovery

PayNow allows customers to retry a payment that failed with a PENDING, REJECTED, or ERROR status. The recovered payment shares the same externalId as the original so PayNow can link the attempts on its side.

use Bpotmalnik\LunarPaynow\Models\PaynowPayment;

$failedPayment = PaynowPayment::findOrFail($id);

$result = Payments::driver('paynow')
    ->recoverFrom($failedPayment)
    ->withData(['continue_url' => route('checkout.complete')])
    ->authorize();

if (! $result->success) {
    return back()->withErrors(['payment' => $result->message]);
}

return redirect($result->redirectUrl);

Recovery must be enabled in the PayNow merchant panel. Calling recoverFrom() on a payment that is not in a recoverable status returns a failure response immediately without hitting the API.

Refunding

Refunds are initiated from the Lunar admin panel via the standard capture transaction interface. To trigger one programmatically, pass the capture Transaction and an amount in grosze (smallest currency unit):

use Lunar\Facades\Payments;

$result = Payments::driver('paynow')
    ->refund($captureTransaction, 5000); // 50.00 PLN

if (! $result->success) {
    // $result->message contains the admin-translated reason.
}

To include a refund reason recognised by PayNow:

$result = Payments::driver('paynow')
    ->withData(['refund_reason' => 'RMA'])
    ->refund($captureTransaction, 5000);

Valid reasons: RMA, REFUND_BEFORE_14, REFUND_AFTER_14, OTHER.

The package validates that:

  • The source payment is CONFIRMED.
  • The requested amount does not exceed the unrefunded balance (taking partial refunds already made into account).
  • The payment is not older than six months (REFUND_POSSIBILITY_EXPIRED).

All amounts are integers in the smallest currency unit. 10000 = 100.00 PLN.

Cancelling a refund

PayNow supports cancelling a refund that is still in NEW status (the awaiting refunds feature, used when your merchant balance is temporarily insufficient).

use Bpotmalnik\LunarPaynow\Models\PaynowRefund;
use Lunar\Facades\Payments;

$refund = PaynowRefund::where('refund_id', $refundId)->firstOrFail();

$result = Payments::driver('paynow')->cancelRefund($refund);

if (! $result->success) {
    // $result->message explains why (e.g. status is no longer NEW).
}

Cancellation is only possible while the refund status is NEW. The package rejects the request locally and does not call the API if the status has already advanced.

Notifications

PayNow sends a POST request to your notification URL whenever a payment status changes to a terminal state (CONFIRMED, REJECTED, ABANDONED, EXPIRED, or ERROR).

The package handles this automatically:

  • Verifies the Signature header using HMAC-SHA256 before processing anything.
  • Uses a database transaction with lockForUpdate to prevent duplicate processing if PayNow sends the same notification twice.
  • On CONFIRMED: marks the intent transaction as successful, creates a capture transaction, sets placed_at on the order, updates the order status, and fires PaymentConfirmed.
  • On failure statuses: marks the intent transaction as failed, updates the order status, and fires PaymentFailed.

Non-terminal statuses (NEW, PENDING) are acknowledged with 200 and ignored — the notification is only fully processed once a final state is reached.

Events

Listen for these events to react to payment outcomes in your application:

use Bpotmalnik\LunarPaynow\Events\PaymentConfirmed;
use Bpotmalnik\LunarPaynow\Events\PaymentFailed;

// In a service provider or EventServiceProvider:
Event::listen(PaymentConfirmed::class, function (PaymentConfirmed $event) {
    $event->order;        // Lunar\Models\Order
    $event->paynowPayment; // Bpotmalnik\LunarPaynow\Models\PaynowPayment
});

Event::listen(PaymentFailed::class, function (PaymentFailed $event) {
    $event->order;
    $event->paynowPayment;
});

Error messages

$result->message from authorize() is always customer-safe — it maps to a localised string the customer can act on, or falls back to a generic message for errors that should not be surfaced (configuration problems, merchant balance issues, etc.).

For the detailed admin-level reason, use $result->adminMessage() or access $result->errorType directly:

$result = Payments::driver('paynow')->cart($cart)->authorize();

if (! $result->success) {
    // Show to customer:
    return back()->withErrors(['payment' => $result->message]);

    // Log for the developer/admin:
    Log::error($result->adminMessage(), [
        'error_type' => $result->errorType?->value,
        'order'      => $result->orderId,
    ]);
}

The refund() and cancelRefund() methods are admin operations — their $result->message is the admin-translated string and is displayed directly in the Lunar admin panel.

Customer-safe errors

These are shown to the customer verbatim; all others fall back to a generic message:

PayNow error Example
SYSTEM_TEMPORARILY_UNAVAILABLE "Payment service is temporarily unavailable."
PAYMENT_AMOUNT_TOO_SMALL "The payment amount is too small."
PAYMENT_AMOUNT_TOO_LARGE "The payment amount is too large."
PAYMENT_METHOD_NOT_AVAILABLE "The selected payment method is not available."
AUTHORIZATION_CODE_EXPIRED "Your BLIK code has expired."
AUTHORIZATION_CODE_INVALID "Invalid BLIK code."
AUTHORIZATION_CODE_USED "This BLIK code has already been used."

Errors such as VERIFICATION_FAILED, INSUFFICIENT_BALANCE_FUNDS, and REFUND_POSSIBILITY_EXPIRED are admin-only — the customer receives the generic fallback.

Translation

The package ships with English and Polish translations. Polish is the default language for the PayNow market, but all strings follow Laravel's standard translation resolution — the active application locale is used automatically.

The language files are split into two sections:

  • errors.admin.* — detailed messages for the Lunar admin panel and application logs.
  • errors.customer.* — short, user-friendly messages safe for browser display.

To customise any string, publish the translations and edit the files under lang/vendor/lunar-paynow/:

php artisan vendor:publish --tag=lunar-paynow-lang

Testing in your application

The package ships with a FakePaynowClient that lets you write feature tests for the full checkout flow without making real HTTP calls to PayNow or needing valid API credentials.

FakePaynowClient

Bpotmalnik\LunarPaynow\Testing\FakePaynowClient implements PaynowClientContract and can be swapped into the container before each test:

use Bpotmalnik\LunarPaynow\Contracts\PaynowClientContract;
use Bpotmalnik\LunarPaynow\Testing\FakePaynowClient;

beforeEach(function () {
    $fake = new FakePaynowClient;
    $this->app->instance(PaynowClientContract::class, $fake);
    $this->paynow = $fake;
});

The fake behaves as follows:

Method Behaviour
createPayment() Returns ['paymentId' => $this->paymentId, 'status' => 'NEW', 'redirectUrl' => $this->redirectUrl]
verifyNotificationSignature() Always returns true — any signature header is accepted
getPaymentStatus() Returns ['paymentId' => $this->paymentId, 'status' => 'CONFIRMED']
createRefund() Returns a minimal successful refund payload
cancelRefund() / getRefundStatus() No-ops / return stubs

Two public properties let you reference the deterministic values in assertions:

$fake->paymentId;    // 'fake-paynow-id-00001'
$fake->redirectUrl;  // 'https://paynow.pl/fake-redirect'

You can override them before the test to simulate different scenarios:

$fake = new FakePaynowClient;
$fake->paymentId = 'custom-id-for-this-test';

Simulating a webhook confirmation

Because verifyNotificationSignature() always returns true, you can POST a fake webhook notification directly in your test without a valid HMAC signature:

$body = json_encode(['paymentId' => $this->paynow->paymentId, 'status' => 'CONFIRMED']);

$this->call(
    'POST',
    route('paynow.notification'),
    [],
    [],
    [],
    ['HTTP_SIGNATURE' => 'fake-sig', 'CONTENT_TYPE' => 'application/json'],
    $body,
);

This triggers the full notification handling path: the PaynowPayment record is updated, a capture transaction is created, placed_at is set on the order, the order status transitions to payment-received, and the PaymentConfirmed event is fired — exactly as in production.

Package development

composer test

composer test runs the full quality suite in order: Pint lint check, PHPStan, unit tests, feature tests.

Individual commands:

composer lint          # fix code style with Pint

composer test:lint     # check style without fixing
composer test:types    # PHPStan static analysis
composer test:unit
composer test:feature

License

lunar-paynow is open-sourced software licensed under the MIT license.