ux2dev/borica

PHP library for BORICA payment services (Cgi gateway, Infopay Checkout)

Maintainers

Package info

github.com/ux2dev/borica

pkg:composer/ux2dev/borica

Statistics

Installs: 21

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0-alpha.2 2026-06-05 19:51 UTC

This package is auto-updated.

Last update: 2026-06-06 02:53:41 UTC


README

PHP library for the BORICA eCommerce CGI payment gateway. Handles request signing, response verification, and all six transaction types defined by the BORICA protocol.

Sponsored by ux2.dev.

Requirements

  • PHP 8.1 or higher
  • OpenSSL extension (ext-openssl)
  • A BORICA merchant account with:
    • Terminal ID (8 alphanumeric characters)
    • Merchant ID
    • RSA private key in PEM format (provided by BORICA or generated per their instructions)

Installation

composer require ux2dev/borica

Migrating to v1.0.0-alpha.2

v1.0.0-alpha.2 unifies all three BORICA services behind a single entry point and adopts the shared ux2dev SDK shape (typed request/result contracts, a response envelope for HTTP calls, and a tenant-scoped Laravel manager).

Single client: the per-service clients (CgiClient, CheckoutClient, ErpClient) are replaced by one Ux2Dev\Borica\Borica instance exposing three service areas:

$borica->cgi()->payments()->purchase(...);
$borica->checkout()->paymentRequests()->create($session, $dto);
$borica->erp()->payments()->createSepa($session, $request);

Typed input DTOs: CGI methods take input DTOs instead of scalar arguments — PaymentInput (purchase / pre-auth), ReferencedPaymentInput (reversal / completion), StatusInput (status check).

Response envelope: Checkout + ERP HTTP calls that return a result DTO now return an Ux2Dev\Borica\Http\ApiResponse. Reach the typed DTO via ->first() (or ->all() for lists). CGI keeps honest return types (a signed request object or a verified callback Response) — there is no HTTP round-trip to wrap.

Config rename: MerchantConfig is now CgiConfig.

Laravel config: the cgi / checkout / erp blocks move under per-tenant entries in a tenants array (see Laravel Integration). Scoping is now per tenant (Borica::tenant('shop-2')->...) rather than per-merchant/per-integration.

Configuration

Outside Laravel, build a Borica instance from a BoricaConfig (each service is optional — a tenant may use only CGI, or all three):

use Psr\Http\Client\ClientInterface;            // any PSR-18 client (e.g. Guzzle)
use Psr\Http\Message\RequestFactoryInterface;   // any PSR-17 factories
use Psr\Http\Message\StreamFactoryInterface;
use Ux2Dev\Borica\Borica;
use Ux2Dev\Borica\Config\BoricaConfig;
use Ux2Dev\Borica\Config\CgiConfig;
use Ux2Dev\Borica\Enum\Currency;
use Ux2Dev\Borica\Enum\Environment;

$cgi = new CgiConfig(
    terminal: 'V1800001',
    merchantId: '1600000001',
    merchantName: 'My Shop',
    privateKey: file_get_contents('/path/to/private_key.pem'),
    environment: Environment::Development,  // or Environment::Production
    currency: Currency::EUR,                // BGN, EUR, or USD
    country: 'BG',                          // default: 'BG'
    timezoneOffset: '+03',                  // default: '+03'
    privateKeyPassphrase: 'secret',         // optional, if key is encrypted
);

$borica = new Borica(
    new BoricaConfig(cgi: $cgi),
    $httpClient,        // PSR-18 (only used by checkout/erp; CGI needs none)
    $requestFactory,    // PSR-17
    $streamFactory,     // PSR-17
);

CgiConfig validates all inputs on construction. The private key and passphrase are never exposed through public properties or serialization. Accessing a service that wasn't configured for the tenant (e.g. $borica->erp() with no ERP config) throws a ConfigurationException.

PSR-3 Logging

Pass any PSR-3 logger as the fifth Borica constructor argument; it is handed to the CGI and Checkout areas.

$borica = new Borica(new BoricaConfig(cgi: $cgi), $httpClient, $requestFactory, $streamFactory, $logger);

Gateway URLs

The gateway URL is determined by the environment:

Environment URL
Development https://3dsgate-dev.borica.bg/cgi-bin/cgi_link
Production https://3dsgate.borica.bg/cgi-bin/cgi_link
$gatewayUrl = $cgi->getGatewayUrl();

Usage

Reach the CGI service area from a configured Borica instance (or the Borica::cgi() facade in Laravel):

$cgi = $borica->cgi();

CGI methods take typed input DTOs and return a signed request object ready to be rendered as a form / POSTed to the gateway.

Payment (Transaction Type 1)

Browser-based payment. Build the request, then POST the form data to the gateway URL.

use Ux2Dev\Borica\Cgi\Request\Input\PaymentInput;

$request = $cgi->payments()->purchase(new PaymentInput(
    amount: '49.99',
    order: '000001',
    description: 'Order #000001',
    mInfo: [],
));

// Build an auto-submitting HTML form
$gatewayUrl = $cgi->getGatewayUrl();
$formFields = $request->toArray();

Render the form:

<form id="borica" method="POST" action="<?= $gatewayUrl ?>">
    <?php foreach ($formFields as $name => $value): ?>
        <input type="hidden" name="<?= $name ?>" value="<?= htmlspecialchars($value) ?>">
    <?php endforeach; ?>
    <button type="submit">Pay</button>
</form>

Optional parameters

$request = $cgi->payments()->purchase(new PaymentInput(
    amount: '49.99',
    order: '000001',
    description: 'Order #000001',
    mInfo: ['cardholderName' => 'John'],   // additional merchant info (base64-encoded JSON)
    adCustBorOrderId: 'MY-SHOP-1234',      // custom order ID shown to customer
    language: 'EN',                         // form language (default: 'BG')
    email: 'customer@example.com',          // customer email
    merchantUrl: 'https://shop.com/notify', // notification URL (must be HTTPS)
));

Pre-Authorization (Transaction Type 12)

Reserves an amount on the customer's card without capturing it. Same input as payment.

$request = $cgi->preAuth()->create(new PaymentInput(
    amount: '100.00',
    order: '000002',
    description: 'Pre-auth for booking #000002',
    mInfo: [],
));

$formFields = $request->toArray();
// POST to $cgi->getGatewayUrl()

Complete Pre-Authorization (Transaction Type 21)

Captures a previously pre-authorized amount. Server-to-server -- POST directly to the gateway.

use Ux2Dev\Borica\Cgi\Request\Input\ReferencedPaymentInput;

$request = $cgi->preAuth()->complete(new ReferencedPaymentInput(
    amount: '100.00',
    order: '000002',
    rrn: $preAuthResponse->getRrn(),       // RRN from the pre-auth response
    intRef: $preAuthResponse->getIntRef(), // INT_REF from the pre-auth response
    description: 'Capture booking #000002',
));

// POST $request->toArray() to $cgi->getGatewayUrl() via HTTP client

Reverse Pre-Authorization (Transaction Type 22)

Releases a pre-authorized hold.

$request = $cgi->preAuth()->reverse(new ReferencedPaymentInput(
    amount: '100.00',
    order: '000002',
    rrn: $preAuthResponse->getRrn(),
    intRef: $preAuthResponse->getIntRef(),
    description: 'Cancel booking #000002',
));

Reversal (Transaction Type 24)

Reverses a completed payment.

$request = $cgi->payments()->reverse(new ReferencedPaymentInput(
    amount: '49.99',
    order: '000001',
    rrn: $paymentResponse->getRrn(),
    intRef: $paymentResponse->getIntRef(),
    description: 'Refund order #000001',
));

Status Check (Transaction Type 90)

Query the status of a previous transaction. Server-to-server.

use Ux2Dev\Borica\Cgi\Request\Input\StatusInput;
use Ux2Dev\Borica\Enum\TransactionType;

$request = $cgi->status()->check(new StatusInput(
    order: '000001',
    transactionType: TransactionType::Purchase, // type of the original transaction
));

// POST $request->toArray() to $cgi->getGatewayUrl() via HTTP client

Parsing the Gateway Response

When BORICA redirects back to your site (for browser-based transactions) or returns an HTTP response (for server-to-server transactions), parse and verify it:

// $data is the associative array from the gateway (e.g. $_POST for callbacks)
$response = $cgi->responses()->parse($data, TransactionType::Purchase);

if ($response->isSuccessful()) {
    $approval = $response->getApproval();
    $rrn = $response->getRrn();
    $intRef = $response->getIntRef();
    // Mark order as paid
} else {
    $error = $response->getErrorMessage();
    // Handle failure
}

The library automatically verifies the P_SIGN signature using the BORICA public key for the configured environment. An InvalidResponseException is thrown if the signature is missing or invalid.

Response Object

The Response object provides getters for all gateway fields:

Method Returns Description
isSuccessful() bool true when ACTION=0 and RC=00
getAction() string Response action code
getRc() string Response code
getApproval() ?string Authorization code
getTerminal() string Terminal ID
getTrtype() string Transaction type
getAmount() ?string Transaction amount
getCurrency() ?string Currency code
getOrder() string Order number
getRrn() ?string Retrieval reference number
getIntRef() ?string Internal reference
getCard() ?string Masked card number
getCardBrand() ?string Card brand (Visa, MC, etc.)
getEci() ?string ECI indicator
getParesStatus() ?string 3DS authentication result
getTimestamp() string Response timestamp (YmdHis, UTC)
getNonce() string Response nonce
getErrorMessage() string Human-readable error description
getStatusMessage() ?string Gateway status message
getCardholderInfo() ?string Cardholder information

Input Validation

The library validates all inputs before signing:

Parameter Rule
amount Positive number, exactly 2 decimal places (e.g. 9.00)
order 1-15 digits
description 1-125 characters, non-empty
email Valid email format (when provided)
merchantUrl Valid HTTPS URL (when provided)
nonce 32 uppercase hex characters (auto-generated if omitted)
timestamp 14 digits, YmdHis format (auto-generated if omitted)
mInfo Encoded size must not exceed 2048 bytes
terminal Exactly 8 alphanumeric characters

A ConfigurationException is thrown when validation fails.

Error Handling

The library defines specific exception types:

Exception When
ConfigurationException Invalid merchant config or request parameters
SigningException Private/public key loading or signing failure
InvalidResponseException Missing or invalid P_SIGN in gateway response

All exceptions extend BoricaException, so you can catch broadly or narrowly:

use Ux2Dev\Borica\Exception\BoricaException;
use Ux2Dev\Borica\Exception\InvalidResponseException;

try {
    $response = $cgi->responses()->parse($data, TransactionType::Purchase);
} catch (InvalidResponseException $e) {
    // Signature verification failed -- do not trust this response
    log($e->getMessage());
    log($e->getResponseData()); // sensitive fields are redacted
} catch (BoricaException $e) {
    // Any other library error
}

Infopay Checkout

BORICA's Infopay Checkout is a REST API for bank-transfer payments (domestic credit transfers, budget transfers, SEPA). It is a separate service from the CGI card-payment gateway and uses its own credentials, private key, and base URL.

Standalone usage

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Ux2Dev\Borica\Borica;
use Ux2Dev\Borica\Config\BoricaConfig;
use Ux2Dev\Borica\InfopayCheckout\Config\CheckoutConfig;
use Ux2Dev\Borica\InfopayCheckout\Dto\Account;
use Ux2Dev\Borica\InfopayCheckout\Dto\CreateSessionRequest;
use Ux2Dev\Borica\InfopayCheckout\Dto\DomesticCreditTransferBgn;
use Ux2Dev\Borica\InfopayCheckout\Dto\InstructedAmount;
use Ux2Dev\Borica\InfopayCheckout\Dto\PaymentRequestDto;
use Ux2Dev\Borica\InfopayCheckout\Enum\InstructedAmountCurrency;
use Ux2Dev\Borica\InfopayCheckout\Enum\PaymentLanguage;

$config = new CheckoutConfig(
    baseUrl: 'https://uat-api-checkout.infopay.bg',
    authId: 'your-auth-id',
    authSecret: 'your-auth-secret',
    shopId: '69e1dbb5-1d28-4059-a5a4-b1b56b84a86d',
    privateKey: file_get_contents('/path/to/checkout-private.key'),
);

$factory = new HttpFactory();
$checkout = (new Borica(new BoricaConfig(checkout: $config), new Client(), $factory, $factory))->checkout();

// 1. Log in to obtain a session
$session = $checkout->sessions()->create(new CreateSessionRequest($config->authId, $config->authSecret))->first();

// 2. Create a payment request (the result DTO is reached via ->first())
$payment = $checkout->paymentRequests()->create(
    session: $session,
    request: new PaymentRequestDto(
        shopId: $config->shopId,
        beneficiaryDefaultAccount: new Account('BG29RZBB91550123456789'),
        instructedAmount: new InstructedAmount(150.00, InstructedAmountCurrency::Bgn),
        details: 'Order No 5679',
        validTime: new DateTimeImmutable('+1 day'),
        externalReferenceId: bin2hex(random_bytes(16)),
        paymentDetails: new DomesticCreditTransferBgn('Pay Invoice 123'),
        successUrl: 'https://merchant.com/success',
        errorUrl: 'https://merchant.com/error',
        language: PaymentLanguage::Bg,
    ),
)->first();

// 3. Redirect the customer to the checkout URL
header('Location: ' . $payment->checkoutUrl);
exit;

// 4. Poll for status (or wait for BORICA callback)
$status = $checkout->paymentRequests()->getStatus($session, $payment->paymentRequestId)->first();

// 5. Close the session when done (close() returns void)
$checkout->sessions()->close($session);

Laravel usage

After adding a tenant with a checkout block (see Laravel Integration), use the facade:

use Ux2Dev\Borica\InfopayCheckout\Dto\CreateSessionRequest;
use Ux2Dev\Borica\Laravel\Facades\Borica;

$checkout = Borica::checkout();

$session = $checkout->sessions()->create(new CreateSessionRequest(
    config('borica.tenants.default.checkout.auth_id'),
    config('borica.tenants.default.checkout.auth_secret'),
))->first();

$payment = $checkout->paymentRequests()->create($session, $paymentDto)->first();

Supported payment types

  • DomesticCreditTransferBgn - domestic BGN credit transfer
  • DomesticBudgetTransferBgn - budget transfer (requires ultimateDebtor + BudgetPaymentDetails)
  • SepaCreditTransfer - SEPA credit transfer

All three extend PaymentDetails and can be passed into PaymentRequestDto::paymentDetails.

HTTP client

The package depends on psr/http-client and psr/http-factory interfaces only. You can inject any PSR-18 client (Guzzle, Symfony HTTP Client, kriswallsmith/buzz, etc). If you don't already have one, composer require guzzlehttp/guzzle provides both the client and a PSR-17 factory out of the box.

JWS signing

POST /v1/api/paymentRequests requires an X-JWS-Signature header over the request body. The library signs the JSON body with the configured private key using RS256 detached JWS (RFC 7515 + RFC 7797's b64=false header). BORICA issues a separate keypair for the Checkout service - do not reuse the CGI signing key.

Infopay ERP Integration

BORICA's Infopay ERP Integration API is a separate REST service for ERP-side workflows — listing accounts and balances, fetching booked transactions, initiating SEPA credit transfers (single and bulk), and issuing invoices. It is unrelated to the Checkout service: no JWS signing, no certificates — just plain JSON with session-based auth.

The merchant receives a uniqueId + accessToken pair as part of the ERP registration; these are exchanged for a session (SessionId + SessionKey) sent as headers on every subsequent call.

Standalone usage

use DateTimeImmutable;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Ux2Dev\Borica\InfopayErp\Config\ErpConfig;
use Ux2Dev\Borica\InfopayErp\Dto\AccountReference;
use Ux2Dev\Borica\InfopayErp\Dto\AddressReference;
use Ux2Dev\Borica\InfopayErp\Dto\AmountRequest;
use Ux2Dev\Borica\InfopayErp\Dto\SepaPayment;
use Ux2Dev\Borica\InfopayErp\Dto\SingleSepaPaymentRequest;
use Ux2Dev\Borica\InfopayErp\Enum\Currency;
use Ux2Dev\Borica\InfopayErp\Enum\SepaServiceLevel;
use Ux2Dev\Borica\InfopayErp\Enum\SessionCreateStatus;
use Ux2Dev\Borica\Borica;
use Ux2Dev\Borica\Config\BoricaConfig;

$config = new ErpConfig(
    baseUrl: 'https://integration.infopay.bg',
    uniqueId: 'a78941c2-3fab-428f-b614-1422b42a0e46',
    accessToken: 'B74xFSWZOEOAxHr8CYjE-u2AUDoWjuF5P6ygqNck7koxu493HovTuh2qxx20z4pG',
);

$factory = new HttpFactory();
$erp = (new Borica(new BoricaConfig(erp: $config), new Client(), $factory, $factory))->erp();

// 1. Create a session — the credentials come from $config (DTO via ->first())
$session = $erp->sessions()->create()->first();
if ($session->status !== SessionCreateStatus::Success) {
    throw new RuntimeException("Auth failed: {$session->status->value}");
}

// 2. List accounts (with balances) — the collection is reached via ->first()
$accounts = $erp->accounts()->list($session, withBalance: true)->first();
foreach ($accounts->accounts as $account) {
    echo "{$account->iban}  {$account->currency}\n";
}

// 3. Trigger sync and wait for it to complete (returns the final collection)
$syncState = $erp->synchronizations()->waitForSync(
    session: $session,
    accountIds: ['acc-uuid-1'],
    timeoutSeconds: 60,
);

// 4. Iterate over every booked transaction in a date range — iterate() yields a
//    Generator and follows the HATEOAS Links.Next.href chain automatically.
foreach ($erp->transactions()->iterate(
    session: $session,
    accountId: 'acc-uuid-1',
    dateFrom: new DateTimeImmutable('2026-01-01'),
    dateTo: new DateTimeImmutable('2026-01-31'),
) as $tx) {
    echo "{$tx->bookingDate?->format('Y-m-d')}  {$tx->transactionAmount->amount}\n";
}

// 5. Initiate a single SEPA credit transfer (result DTO via ->first())
$payment = $erp->payments()->createSepa($session, new SingleSepaPaymentRequest(
    debtorAccount: new AccountReference('BG80BNBG96611020345678'),
    payment: new SepaPayment(
        creditorName: 'Acme GmbH',
        creditorAccount: new AccountReference('DE89370400440532013000'),
        creditorAddress: new AddressReference(country: 'DE', city: 'Berlin'),
        instructedAmount: new AmountRequest('150.00', Currency::Eur),
        remittanceInformationUnstructured: 'Invoice 2026-001',
        serviceLevel: SepaServiceLevel::Inst,
    ),
))->first();

// 6. The bank may require SCA confirmation in a browser
header('Location: ' . $payment->links?->scaRedirect);

// 7. Close the session when done (close() returns void)
$erp->sessions()->close($session);

Envelope cheatsheet: methods that return a result DTO (create, createSepa, getStatus, accounts()->list/get, currentState, transactions()->list, missingDates, check) return an ApiResponse — unwrap with ->first() / ->all(). Methods that don't (refresh, close → void; iterateGenerator; waitForSync → collection) return their value directly.

Laravel usage

After adding a tenant with an erp block (see Laravel Integration), use the facade:

use Ux2Dev\Borica\Laravel\Facades\Borica;

$erp = Borica::erp();
$session = $erp->sessions()->create()->first();
$accounts = $erp->accounts()->list($session, withBalance: true)->first();

Session lifecycle is the caller's responsibility

Sessions are stateful and finite: BORICA expires them after a period of inactivity, and any authenticated call against an expired session returns HTTP 401. The library is intentionally stateless on this point — it does not auto-refresh sessions or retry on 401. That belongs in the integration layer, where you can decide whether to re-auth, surface the failure, or queue a retry.

For Laravel projects the recommended pattern is a thin wrapper around Borica::erp() that:

  • Caches the active Session (e.g. in the cache/session store, keyed by integration name).
  • Catches Ux2Dev\Borica\Exception\AuthenticationException from any resource call, calls sessions()->create() to mint a new session, and retries the original call once.
  • Periodically calls sessions()->check() to validate before long batch jobs.

This will be added as opt-in middleware in a future release.

Available resources

Resource Methods Purpose
sessions() create(), check(), close() Session lifecycle
synchronizations() refresh(), currentState(), waitForSync() Trigger and poll for balance/transaction sync
accounts() list(), get() Inspect linked bank accounts
transactions() list(), iterate(), missingDates() Paginated transaction history + sync gap detection
payments() createSepa(), getStatus() Single SEPA credit transfer
bulkPayments() createSepa(), getStatus() Batch SEPA credit transfer (2..250 payments)
invoices() create() Issue an invoice with polymorphic content (with/without VAT) and payment method

The library covers SEPA payment paths only — domestic BGN credit/budget transfers from the spec are intentionally out of scope. Open an issue if you need them.

Pagination

transactions()->list() returns one page (TransactionsPage) with the booked transactions and an optional nextUrl(). For convenience, transactions()->iterate() returns a Generator<Transaction> that follows the Links.Next.href chain transparently and resolves relative URLs against the configured base URL.

Sync polling

ERP sync is asynchronous: POST /api/synchronizations/.../refresh returns 204 immediately, and you must poll GET /api/synchronizations/.../currentState until every account leaves the Processing state. waitForSync() wraps both calls with exponential backoff and a configurable timeout — it throws RuntimeException if the sync hasn't finished by the deadline.

Polymorphic invoice payloads

Three areas of the invoice schema use OpenAPI oneOf discriminators. Each is modeled as an abstract base + concrete subclasses:

  • ContentContentWithVat / ContentWithoutVat (discriminator contentType)
  • PaymentMethodBankTransfer / CashPaymentMethod / CardPaymentMethod / OtherPaymentMethod (discriminator paymentType)
  • VatRateZeroVat / NonZeroVat (discriminator vatRateType)

Pick the concrete subclass when constructing an InvoiceCreateRequest; the discriminator field is set automatically during serialization.

Spec quirks preserved on the wire

A handful of property names in the ERP spec contain typos. The library uses the correct spelling at the PHP boundary but preserves the exact wire form so requests round-trip cleanly:

  • DebitorAccount (should be Debtor) — used in payment requests; PHP property is debtorAccount.
  • TransactioneCurrentState (extra e) — used in BalancesAndTransactionsCurrentStateResponse; PHP property is transactionCurrentState.
  • InvaliCredentials (missing d) — SessionCreateStatus enum case for failed auth.

Laravel Integration

The library includes a Laravel integration layer that works with Laravel 10, 11, 12, and 13. The core library remains framework-agnostic -- the Laravel code lives entirely in src/Laravel/.

Setup

The package auto-discovers via extra.laravel.providers in composer.json. No manual registration needed.

Publish the config file:

php artisan vendor:publish --tag=borica-config

Add your merchant credentials to .env:

BORICA_TERMINAL=V1800001
BORICA_MERCHANT_ID=1600000001
BORICA_MERCHANT_NAME="My Shop"
BORICA_PRIVATE_KEY=/path/to/private.key
BORICA_ENVIRONMENT=development
BORICA_CURRENCY=EUR

The private_key config accepts either a file path or a raw PEM string.

The published config/borica.php groups all three services under per-tenant entries in a tenants array. Each service block is optional:

return [
    'default' => env('BORICA_TENANT', 'default'),

    'tenants' => [
        'default' => [
            'environment' => env('BORICA_ENVIRONMENT', 'production'),

            'cgi' => [
                'terminal'               => env('BORICA_TERMINAL'),
                'merchant_id'            => env('BORICA_MERCHANT_ID'),
                'merchant_name'          => env('BORICA_MERCHANT_NAME'),
                'currency'               => env('BORICA_CURRENCY', 'EUR'),
                'private_key'            => env('BORICA_PRIVATE_KEY'),
                'private_key_passphrase' => env('BORICA_PRIVATE_KEY_PASSPHRASE'),
                'borica_public_key'      => env('BORICA_PUBLIC_KEY'),
            ],

            'checkout' => [
                'base_url'               => env('BORICA_CHECKOUT_BASE_URL'),
                'auth_id'                => env('BORICA_CHECKOUT_AUTH_ID'),
                'auth_secret'            => env('BORICA_CHECKOUT_AUTH_SECRET'),
                'shop_id'                => env('BORICA_CHECKOUT_SHOP_ID'),
                'private_key'            => env('BORICA_CHECKOUT_PRIVATE_KEY'),
                'private_key_passphrase' => env('BORICA_CHECKOUT_PRIVATE_KEY_PASSPHRASE'),
            ],

            'erp' => [
                'base_url'     => env('BORICA_ERP_BASE_URL'),
                'unique_id'    => env('BORICA_ERP_UNIQUE_ID'),
                'access_token' => env('BORICA_ERP_ACCESS_TOKEN'),
            ],
        ],
    ],

    'routes' => [
        'enabled'    => true,
        'prefix'     => 'borica',
        'middleware' => ['web'],
    ],

    'redirect' => [
        'success' => '/payment/success',
        'failure' => '/payment/failure',
    ],
];

Facade

use Ux2Dev\Borica\Cgi\Request\Input\PaymentInput;
use Ux2Dev\Borica\Laravel\Facades\Borica;

// Create a payment request using the default tenant
$request = Borica::cgi()->payments()->purchase(new PaymentInput(
    amount: '49.99',
    order: '000001',
    description: 'Order #000001',
    mInfo: ['cardholderName' => 'John Doe', 'email' => 'john@example.com'],
));

$gatewayUrl = Borica::cgi()->getGatewayUrl();
$formFields = $request->toArray();

Multiple Tenants

Define additional tenants in config/borica.php:

'tenants' => [
    'default' => [ /* ... */ ],
    'second-shop' => [
        'environment' => 'production',
        'cgi' => [
            'terminal' => env('BORICA_SECOND_TERMINAL'),
            'merchant_id' => env('BORICA_SECOND_MERCHANT_ID'),
            // ...
        ],
    ],
],

Switch tenant with the immutable tenant() method:

Borica::tenant('second-shop')->cgi()->payments()->purchase($input);
Borica::tenant('second-shop')->checkout()->sessions()->create($req)->first();

Dynamic Terminal Resolution

For applications where tenants are stored in a database, register a custom terminal resolver in a service provider. The resolver returns a tenant config array (the same nested shape as config('borica.tenants.*')):

use Ux2Dev\Borica\Laravel\Facades\Borica;

public function boot(): void
{
    Borica::resolveTerminalUsing(function (string $terminal): ?array {
        $tenant = Tenant::where('borica_terminal', $terminal)->first();
        if (!$tenant) return null;

        return [
            'name' => $tenant->slug,             // tenant name (defaults to the terminal)
            'environment' => $tenant->borica_environment,
            'cgi' => [
                'terminal' => $tenant->borica_terminal,
                'merchant_id' => $tenant->borica_merchant_id,
                'merchant_name' => $tenant->company_name,
                'private_key' => $tenant->borica_private_key_path,
                'currency' => $tenant->currency,
            ],
        ];
    });
}

This resolver is used automatically when BORICA sends callbacks -- the middleware maps the TERMINAL field in the POST data to a tenant via Borica::tenantByTerminal($terminal).

Callback Handling

The package registers a POST /borica/callback route that:

  1. Verifies the P_SIGN signature via the VerifyBoricaSignature middleware
  2. Dispatches events based on the transaction result
  3. Redirects to config('borica.redirect.success') or config('borica.redirect.failure')

The callback route is automatically excluded from CSRF verification.

Events

Listen for these events to process payment results:

Event When
BoricaResponseReceived Every callback, regardless of result
BoricaPaymentSucceeded Purchase (type 1) succeeded
BoricaPaymentFailed Purchase (type 1) failed
BoricaPreAuthSucceeded Pre-auth (type 12) succeeded
BoricaPreAuthFailed Pre-auth (type 12) failed
use Ux2Dev\Borica\Laravel\Events\BoricaPaymentSucceeded;

class HandlePayment
{
    public function handle(BoricaPaymentSucceeded $event): void
    {
        $response = $event->response;
        $merchantName = $event->merchantName;

        // Mark order as paid
        Order::where('borica_order', $response->getOrder())
            ->update(['status' => 'paid', 'rrn' => $response->getRrn()]);
    }
}

Customizing Routes

Publish the routes file to customize the callback endpoint:

php artisan vendor:publish --tag=borica-routes

Or disable the built-in route entirely and define your own:

// config/borica.php
'routes' => ['enabled' => false],

Artisan Commands

Generate Certificate

Interactive command to generate an RSA private key and CSR for BORICA merchant registration:

php artisan borica:generate-certificate
php artisan borica:generate-certificate --tenant=default  # pre-fills terminal from config

Status Check

Check the status of a transaction:

php artisan borica:status-check 000001 --type=purchase
php artisan borica:status-check 000001 --type=pre-auth --tenant=second-shop

Valid --type values: purchase, pre-auth, pre-auth-complete, pre-auth-reversal, reversal.

Security

  • Request signing uses RSA-SHA256 via OpenSSL
  • Response P_SIGN is verified against BORICA's public key before any data is returned
  • Private key material is never exposed through var_dump, serialization, or public properties
  • Sensitive response fields (CARD, APPROVAL, P_SIGN, RRN, INT_REF, CARDHOLDERINFO) are redacted in exception data and serialization
  • Nonce (128-bit random) and timestamp are auto-generated per request to prevent replay
  • BORICA public keys include integrity fingerprints to detect tampering

Testing

The library uses Pest for testing. The test suite covers all transaction types, signing/verification, MAC construction, configuration validation, response parsing, and error codes.

Running the tests

composer install
vendor/bin/pest

Test structure

tests/
  CgiClientTest.php                      # Integration tests (full request/response round-trip)
  Config/CgiConfigTest.php               # Config validation, defaults, serialization guard
  Certificate/CertificateGeneratorTest.php  # CSR/key generation, validation, file output
  Signing/SignerTest.php                 # RSA-SHA256 sign/verify, passphrase, invalid keys
  Signing/MacGeneralTest.php             # MAC field ordering for all transaction types
  Cgi/Request/PaymentRequestTest.php     # Payment request fields and signing fields
  Cgi/Request/PreAuthRequestTest.php     # Pre-authorization request
  Cgi/Request/PreAuthCompleteRequestTest.php
  Cgi/Request/PreAuthReversalRequestTest.php
  Cgi/Request/ReversalRequestTest.php
  Cgi/Request/StatusCheckRequestTest.php
  Cgi/Response/ResponseParserTest.php    # P_SIGN verification, tampered/missing signatures
  Cgi/Response/ResponseTest.php          # Response object, success/failure, error messages
  ErrorCode/GatewayErrorTest.php         # Gateway error code lookups
  ErrorCode/IssuerErrorTest.php          # Issuer error code lookups
  Laravel/
    TestCase.php                         # Orchestra Testbench base class
    BoricaServiceProviderTest.php        # Config merging, singleton, routes, commands
    BoricaManagerTest.php               # Multi-merchant resolution, caching, key resolution
    FacadeTest.php                       # Facade proxy verification
    BoricaCallbackControllerTest.php     # Event dispatching, redirects
    VerifyBoricaSignatureTest.php        # Signature verification, 403 on failure
    EventsTest.php                       # All 5 event classes
    ConfigResolutionTest.php             # Config structure validation
    GenerateCertificateCommandTest.php   # Certificate generation command
    StatusCheckCommandTest.php           # Status check command
  fixtures/
    test_private_key.pem                 # Unencrypted RSA 2048-bit key (test only)
    test_private_key_encrypted.pem       # Passphrase-protected key (passphrase: "testpass")
    test_public_key.pem                  # Matching public key

Test fixtures

The tests/fixtures/ directory contains RSA key pairs for testing only. These keys are not used in any environment and have no relation to BORICA's actual keys. The test suite uses them for sign/verify round-trips without requiring a real merchant account.

Writing tests against the library

When testing your own integration code, build a Borica instance (or a CgiArea directly) using the development environment and your own test keys. For response parsing tests, sign a mock response with your test private key and pass the matching public key to parse():

$response = $cgi->responses()->parse(
    $mockResponseData,
    TransactionType::Purchase,
    $testPublicKey,  // override the BORICA public key for testing
);

Sponsor

Built and maintained by ux2.dev.

License

MIT -- see LICENSE.