gigoshkin/bog-payments-php

Bank of Georgia Payments API client library for PHP 8.2+

Maintainers

Package info

github.com/gigoshkin/bog-payments-php

pkg:composer/gigoshkin/bog-payments-php

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.0 2026-04-15 08:20 UTC

This package is auto-updated.

Last update: 2026-04-15 09:13:31 UTC


README

CI PHP License: MIT PHPUnit PSR-18 codecov Mutation testing badge Packagist Version

A standalone, framework-agnostic PHP 8.2+ client for the Bank of Georgia Payments API. Zero framework coupling in production — drop it into Symfony, Laravel, or any PSR-compatible project by injecting standard interfaces.

Requirements

  • PHP 8.2+
  • ext-openssl, ext-json
  • A PSR-18 HTTP client (e.g. Guzzle, Symfony HttpClient)
  • PSR-17 request/stream factories (e.g. Nyholm PSR-7, Guzzle PSR-7)

Installation

composer require gigoshkin/bog-payments-php

You also need a concrete HTTP client (not included to keep the library decoupled):

# Option A — Guzzle
composer require guzzlehttp/guzzle guzzlehttp/psr7

# Option B — Symfony HttpClient
composer require symfony/http-client nyholm/psr7

Quick Start

use Bog\Payments\BogClient;
use Bog\Payments\BogConfig;
use Bog\Payments\Dto\Request\BasketItem;
use Bog\Payments\Dto\Request\CreateOrderRequest;
use Bog\Payments\Enum\Currency;

// 1. Configure
$config = new BogConfig(
    clientId:         'your-client-id',
    clientSecret:     'your-client-secret',
    webhookPublicKey: file_get_contents('/path/to/bog-public-key.pem'), // optional
);

// 2. Wire up (example with Guzzle)
$httpClient  = new \GuzzleHttp\Client();
$psr17Factory = new \GuzzleHttp\Psr7\HttpFactory();

$client = BogClient::create($config, $httpClient, $psr17Factory, $psr17Factory);

// 3. Create an order
$order = $client->createOrder(new CreateOrderRequest(
    callbackUrl:     'https://your-shop.com/webhook/bog',
    totalAmount:     150.00,
    basket:          [
        new BasketItem(productId: 'SKU-001', quantity: 2, unitPrice: 50.00, description: 'Widget'),
        new BasketItem(productId: 'SKU-002', quantity: 1, unitPrice: 50.00),
    ],
    currency:        Currency::GEL,
    externalOrderId: 'ORDER-12345',
));

// 4. Redirect the customer
header('Location: ' . $order->redirectUrl);

Configuration

$config = new BogConfig(
    clientId:         'your-client-id',      // required
    clientSecret:     'your-client-secret',  // required
    baseUrl:          'https://api.bog.ge',  // default
    tokenUrl:         'https://oauth2.bog.ge/auth/realms/bog/protocol/openid-connect/token', // default
    ttlBufferSeconds: 30,                    // shave off from token TTL to avoid mid-request expiry
    webhookPublicKey: '-----BEGIN PUBLIC KEY-----...', // PEM string; required for webhook verification
);

API Reference

Orders

// Create an order — returns a redirect URL for the payment portal
$response = $client->createOrder(CreateOrderRequest $request, ?string $idempotencyKey = null): CreateOrderResponse;
// $response->orderId      — BOG order ID
// $response->redirectUrl  — send the customer here (null for Apple Pay / no-3DS Google Pay)
// $response->detailsUrl   — receipt polling URL
// $response->acceptUrl    — Apple Pay accept endpoint (null for standard orders)
// $response->status       — set when payment completes synchronously (e.g. no-3DS Google Pay)

// Fetch full receipt / payment status
$details = $client->getOrderDetails(string $orderId): OrderDetails;
// $details->id                     — BOG order ID
// $details->status                 — OrderStatus enum
// $details->currency               — Currency enum
// $details->requestedAmount        — amount requested at creation
// $details->processedAmount        — amount actually transferred / authorized
// $details->refundedAmount         — total refunded so far
// $details->createdAt              — DateTimeImmutable
// $details->expiresAt              — DateTimeImmutable
// $details->externalOrderId        — your system's order ID
// $details->capture                — "automatic" or "manual" (null if not returned)
// $details->paymentMethod          — PaymentMethod enum (null before payment)
// $details->maskedCard             — e.g. "400000***0001" (null before payment)
// $details->transactionId          — bank transaction ID
// $details->pgTrxId                — payment gateway transaction ID
// $details->cardType               — "visa", "mc", "amex" (null before payment)
// $details->cardExpiryDate         — "MM/YY" format
// $details->authCode               — payment authorization code
// $details->parentOrderId          — set when charged via a saved card
// $details->paymentOption          — "recurrent", "direct_debit", etc.
// $details->savedCardType          — "subscription", "recurrent", etc.
// $details->paymentCode            — PaymentStatusCode enum
// $details->paymentCodeDescription — human-readable code description
// $details->buyerName, ->buyerEmail, ->buyerPhone
// $details->rejectionReason        — "expiration", "unknown", or bank code description
// $details->actions                — array of OrderAction

CreateOrderRequest options

new CreateOrderRequest(
    callbackUrl:         'https://your-shop.com/webhook/bog', // required
    totalAmount:         100.00,                              // required, > 0
    basket:              [new BasketItem(...)],               // required, at least one item
    currency:            Currency::GEL,                      // default GEL
    capture:             CaptureMode::Automatic,             // or Manual for pre-authorization
    externalOrderId:     'ORDER-123',
    redirectUrl:         'https://your-shop.com/success',    // success redirect
    failUrl:             'https://your-shop.com/fail',       // failure redirect
    ttl:                 30,                                  // minutes until expiry (2–1440)
    paymentMethods:      [PaymentMethod::Card, PaymentMethod::GooglePay], // restrict methods
    buyer:               new BuyerInfo(fullName: 'John Doe', maskedEmail: 'j***@example.com'),
    totalDiscountAmount: 10.00,
    deliveryAmount:      5.00,
    splitTransfers:      [new SplitTransfer('GE00...', 70.00), new SplitTransfer('GE00...', 30.00)],
    googlePay:           new ExternalGooglePayConfig($googlePayToken), // server-side Google Pay
    applePay:            new ExternalApplePayConfig(),                 // server-side Apple Pay
);

BasketItem options

new BasketItem(
    productId:          'SKU-001',   // required
    quantity:           2,           // required
    unitPrice:          50.00,       // required
    description:        'Widget',
    unitDiscountPrice:  5.00,
    vat:                9.00,
    vatPercent:         18.0,
    totalPrice:         90.00,
    image:              'https://cdn.example.com/widget.jpg',
    packageCode:        'A000123',
    tin:                '123456789',
    pinfl:              '12345678901234',
    productDiscountId:  'PROMO2024',
);

Refunds

// Full refund (omit amount — works for card, Apple Pay, Google Pay, BOG authorization)
$result = $client->refund('order-id', new RefundRequest());

// Partial refund (cards, Apple Pay, Google Pay only)
$result = $client->refund('order-id', new RefundRequest(amount: 25.00));

// $result->key       — 'request_received'
// $result->message   — human-readable confirmation
// $result->actionId  — UUID; use same idempotency key to re-fetch same action

// Idempotent retry — same key = same actionId returned
$result = $client->refund('order-id', new RefundRequest(), idempotencyKey: 'my-idem-key');

Google Pay (Server-Side / External)

When you collect the Google Pay token yourself (via the Google Pay JS SDK in your frontend), pass it directly to BOG:

use Bog\Payments\Dto\Request\ExternalGooglePayConfig;

$order = $client->createOrder(new CreateOrderRequest(
    callbackUrl: 'https://your-shop.com/webhook/bog',
    totalAmount: 50.00,
    basket:      [...],
    googlePay:   new ExternalGooglePayConfig($googlePayToken), // token from Google Pay JS SDK
));

// If no 3DS is required, BOG completes the payment synchronously:
if ($order->status === 'completed') {
    // payment done — no redirect needed
} else {
    // 3DS required — redirect the customer
    header('Location: ' . $order->redirectUrl);
}

Apple Pay (Server-Side / External)

When you run your own Apple Pay session and collect the payment token, pass it to BOG in two steps:

use Bog\Payments\Dto\Request\ExternalApplePayConfig;

// Step 1 — create an Apple Pay order
$order = $client->createOrder(new CreateOrderRequest(
    callbackUrl: 'https://your-shop.com/webhook/bog',
    totalAmount: 50.00,
    basket:      [...],
    applePay:    new ExternalApplePayConfig(),
));
// $order->redirectUrl is null — Apple Pay orders don't redirect
// $order->acceptUrl   is the endpoint to POST the Apple Pay token to

// Step 2 — complete the payment with the token from Apple Pay JS
$result = $client->completeApplePayPayment(
    $order->orderId,
    $applePayToken, // from Apple Pay JS onpaymentauthorized event
);
// $result->status === 'completed' means payment succeeded

Pre-Authorization (Manual Capture)

Pre-authorization blocks funds without charging. The order status becomes OrderStatus::PreAuthorizationBlocked once the customer pays. You then either confirm (capture) or cancel (release) the hold.

use Bog\Payments\Dto\Request\CancelPreAuthRequest;
use Bog\Payments\Dto\Request\ConfirmPreAuthRequest;
use Bog\Payments\Enum\CaptureMode;

// Step 1 — create a manual-capture order
$order = $client->createOrder(new CreateOrderRequest(
    callbackUrl: 'https://your-shop.com/webhook/bog',
    totalAmount: 200.00,
    basket:      [...],
    capture:     CaptureMode::Manual,
));
// Redirect the customer to $order->redirectUrl
// When status = OrderStatus::PreAuthorizationBlocked, funds are held.

// Step 2a — confirm (capture) the full amount
$result = $client->confirmPreAuthorization(
    $order->orderId,
    new ConfirmPreAuthRequest(description: 'Order fulfilled'),
);

// Step 2b — confirm a partial amount
$result = $client->confirmPreAuthorization(
    $order->orderId,
    new ConfirmPreAuthRequest(amount: 150.00, description: 'Partial fulfillment'),
);

// Step 2c — cancel (release the hold entirely)
$result = $client->cancelPreAuthorization(
    $order->orderId,
    new CancelPreAuthRequest(description: 'Out of stock'),
);

// $result->key, $result->message, $result->actionId

// IMPORTANT: confirm and cancel are mutually exclusive on the same order.
// Each needs its own fresh pre-authorized order.

Saved Cards

BOG supports two saved-card flows. In both cases the save call must happen before redirecting the customer to the payment page — BOG links the card during checkout.

Offline / Automatic charging (no customer interaction on future charges)

use Bog\Payments\Dto\Request\SubscribeRequest;

// Step 1 — create an order
$order = $client->createOrder(new CreateOrderRequest(
    callbackUrl: 'https://your-shop.com/webhook/bog',
    totalAmount: 50.00,
    basket:      [...],
));

// Step 2 — register intent to save for automatic charging (BEFORE redirecting customer)
$client->saveCardAutomatic($order->orderId);

// Step 3 — redirect customer to $order->redirectUrl; they pay once

// Step 4 — charge without customer interaction on future billings
// IMPORTANT: store the idempotency key before calling — use it on retry to avoid double-charges
$idempotencyKey = $myKeyGenerator->generate();
$charged = $client->chargeCard(
    $order->orderId,
    new SubscribeRequest(
        callbackUrl:     'https://your-shop.com/webhook/bog',
        externalOrderId: 'SUBSCRIPTION-CYCLE-42',
    ),
    $idempotencyKey,
);
// $charged->orderId     — new order ID for this charge
// $charged->detailsUrl  — receipt URL

Recurrent / User-present charging (customer re-authenticates on future charges)

// Step 1 — create an order
$order = $client->createOrder(new CreateOrderRequest(
    callbackUrl: 'https://your-shop.com/webhook/bog',
    totalAmount: 50.00,
    basket:      [...],
));

// Step 2 — register intent to save for recurrent charging (BEFORE redirecting customer)
$client->saveCard($order->orderId);

// Step 3 — redirect customer to $order->redirectUrl; they pay once

// Step 4 — create a new order using the saved card (customer will be redirected to re-authenticate)
$newOrder = $client->createRecurrentOrder(
    $order->orderId,                  // parent order ID where card was saved
    new CreateOrderRequest(
        callbackUrl:     'https://your-shop.com/webhook/bog',
        totalAmount:     50.00,
        basket:          [...],
        externalOrderId: 'RENEWAL-' . $cycleId,
    ),
);
// Redirect customer to $newOrder->redirectUrl

Delete a saved card

$client->deleteCard($order->orderId);

Webhooks

// In your webhook controller:
$rawBody         = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_CALLBACK_SIGNATURE'] ?? '';

try {
    $payload = $client->verifyAndParseWebhook($rawBody, $signatureHeader);
    // $payload->event       — e.g. 'order_payment'
    // $payload->order       — full OrderDetails DTO
    // $payload->requestTime — DateTimeImmutable

    match ($payload->order->status) {
        OrderStatus::Completed        => handleSuccess($payload->order),
        OrderStatus::Refunded         => handleRefund($payload->order),
        OrderStatus::PartiallyRefunded => handlePartialRefund($payload->order),
        default                        => null,
    };

    http_response_code(200);
} catch (\Bog\Payments\Exception\WebhookVerificationException $e) {
    http_response_code(400);
}

If you don't need signature verification (e.g. IP-allowlisted endpoint):

$payload = $client->parseWebhook($rawBody);

Caching

Token caching is controlled via CacheInterface. The default InMemoryCache is single-process only. For production multi-process environments (PHP-FPM, RoadRunner) supply a shared cache:

use Bog\Payments\Cache\CacheInterface;

class RedisCacheAdapter implements CacheInterface
{
    public function __construct(private \Redis $redis) {}

    public function get(string $key): mixed
    {
        $value = $this->redis->get($key);
        return $value === false ? null : unserialize($value);
    }

    public function set(string $key, mixed $value, int $ttl): void
    {
        $this->redis->setex($key, $ttl, serialize($value));
    }

    public function delete(string $key): void
    {
        $this->redis->del($key);
    }
}

$client = BogClient::create($config, $httpClient, $requestFactory, $streamFactory, new RedisCacheAdapter($redis));

Framework Integration

Symfony (services.yaml)

services:
    Bog\Payments\BogConfig:
        arguments:
            $clientId:         '%env(BOG_CLIENT_ID)%'
            $clientSecret:     '%env(BOG_CLIENT_SECRET)%'
            $webhookPublicKey: '%env(BOG_WEBHOOK_PUBLIC_KEY)%'

    Bog\Payments\BogClient:
        factory: ['Bog\Payments\BogClient', 'create']
        arguments:
            $config:         '@Bog\Payments\BogConfig'
            $httpClient:     '@psr18.client'
            $requestFactory: '@Psr\Http\Message\RequestFactoryInterface'
            $streamFactory:  '@Psr\Http\Message\StreamFactoryInterface'
            $cache:          '@Bog\Payments\Cache\CacheInterface'

Laravel (Service Provider)

use Bog\Payments\BogClient;
use Bog\Payments\BogConfig;

$this->app->singleton(BogClient::class, function ($app) {
    $config = new BogConfig(
        clientId:         config('bog.client_id'),
        clientSecret:     config('bog.client_secret'),
        webhookPublicKey: config('bog.webhook_public_key'),
    );

    return BogClient::create(
        $config,
        $app->make(\GuzzleHttp\Client::class),
        new \GuzzleHttp\Psr7\HttpFactory(),
        new \GuzzleHttp\Psr7\HttpFactory(),
        $app->make(\Bog\Payments\Cache\CacheInterface::class),
    );
});

Error Handling

All exceptions extend Bog\Payments\Exception\BogException.

Exception When
AuthenticationException Invalid credentials or persistent 401 after token refresh
OrderNotFoundException Order ID not found (BOG returns 400 "Invalid order_id" — normalized to this)
ApiException Any other non-2xx response; exposes $statusCode and $responseBody
NetworkException PSR-18 transport failure (timeout, DNS, etc.)
WebhookVerificationException Signature mismatch or missing public key
use Bog\Payments\Exception\ApiException;
use Bog\Payments\Exception\NetworkException;
use Bog\Payments\Exception\OrderNotFoundException;

try {
    $details = $client->getOrderDetails($orderId);
} catch (OrderNotFoundException $e) {
    // order does not exist
} catch (ApiException $e) {
    // $e->statusCode, $e->responseBody
} catch (NetworkException $e) {
    // retry or alert
}

Running Tests

composer install

# Unit and integration tests (no credentials needed)
./vendor/bin/phpunit --testsuite unit,integration --testdox

# Static analysis (PHPStan level 8)
./vendor/bin/phpstan analyse src --level=8

# Mutation testing
php -d pcov.enabled=1 -d pcov.directory=. ./vendor/bin/phpunit \
    --testsuite unit,integration \
    --coverage-xml=build/coverage --log-junit=build/junit.xml
./vendor/bin/infection --coverage=build --threads=4

Sandbox tests

Real HTTP tests against the BOG sandbox environment. Requires sandbox credentials from businessmanager.bog.ge.

# Copy the example env file and fill in your credentials
cp .env.sandbox.example .env.sandbox
# BOG_SANDBOX_CLIENT_ID=...
# BOG_SANDBOX_CLIENT_SECRET=...

# Run all automated sandbox tests
./vendor/bin/phpunit --testsuite sandbox --testdox

# Run interactive end-to-end flow tests (requires a real terminal — prompts you to pay in a browser)
./vendor/bin/phpunit --testsuite sandbox-interactive --testdox

Interactive tests cover the full payment lifecycle — they create orders, print a URL for you to open in your browser, wait for you to complete payment, then assert the resulting state. They auto-skip in CI environments (no TTY detected).

License

MIT