Search by

blob-solutions / vcr-am-sdk

lobotomoe

Official PHP SDK for the VCR.AM Virtual Cash Register API — fiscalize sales, refunds, and prepayments through Armenia's State Revenue Committee.

Package info

github.com/blob-am/vcr-am-sdk-php

Homepage

Issues

pkg:composer/blob-solutions/vcr-am-sdk

Statistics

Installs: 496

Dependents: 1

Suggesters: 0

Stars: 0

v0.11.2 2026-09-30 10:20 UTC

This package is auto-updated.

Last update: 2026-09-30 10:21:06 UTC


README

Packagist Version PHP Version Require License CI

Official PHP SDK for the VCR.AM Virtual Cash Register API. Fiscalize sales, refunds, and prepayments through Armenia's State Revenue Committee — without touching XML, PSR-7, or wire-format quirks.

A native sibling to the TypeScript SDK. Same endpoints, same error semantics, same response validation philosophy — adapted to idiomatic PHP 8.2+.

Status

Pre-release. API is being developed in lockstep with the TypeScript SDK. While the package is on 0.x, every minor release may introduce breaking changes — pin tightly until 1.0. See CHANGELOG.md for release notes.

⚠️ Do not use v0.1.0. The very first release shipped with the wrong auth header and is rejected by every production request. Pin ^0.1.1 or later.

Requirements

  • PHP 8.2 or newer (8.1 hit security EOL on 2025-12-31)
  • Composer 2.x
  • A VCR.AM account and API key — sign up at vcr.am

Installation

composer require blob-solutions/vcr-am-sdk

The package ships with sensible defaults (Guzzle 7 as the PSR-18 HTTP client, nyholm/psr7 as the PSR-7/PSR-17 implementation). If your application already uses different implementations, the SDK will discover and reuse them via php-http/discovery.

Quick start

Register a sale of two loaves of bread, paid in cash:

use BlobSolutions\VcrAm\Input\Buyer;
use BlobSolutions\VcrAm\Input\CashierId;
use BlobSolutions\VcrAm\Input\Offer;
use BlobSolutions\VcrAm\Input\RegisterSaleInput;
use BlobSolutions\VcrAm\Input\SaleAmount;
use BlobSolutions\VcrAm\Input\SaleItem;
use BlobSolutions\VcrAm\Unit;
use BlobSolutions\VcrAm\VcrClient;

$client = new VcrClient(apiKey: $_ENV['VCR_AM_API_KEY']);

$response = $client->registerSale(new RegisterSaleInput(
    cashier: CashierId::byDeskId('desk-1'),
    items: [
        new SaleItem(
            offer: Offer::existing('sku-bread'),
            quantity: '2',
            price: '750',
            unit: Unit::Piece,
        ),
    ],
    amount: new SaleAmount(cash: '1500'),
    buyer: Buyer::individual(),
));

echo "Sale {$response->saleId} fiscalised as {$response->fiscal}\n";
echo "Public receipt: https://vcr.am/{$response->urlId}\n";

Every input DTO is constructor-validated, so malformed payloads fail at the call site — not after a round-trip to the SRC. All decimal monetary values are passed as strings to preserve precision over the wire (see Monetary precision below).

Pairing a store with a register

If your software is installed by merchants — a WooCommerce plugin, a POS, a booking system — do not ask them to paste an API key. PairingClient gets you one through the merchant's own browser: they click "connect" in your software, approve on vcr.am against a register they already manage, and come back paired.

It takes no API key, because obtaining the first one is the point.

use BlobSolutions\VcrAm\Input\RegisterPairingRequestInput;
use BlobSolutions\VcrAm\Pairing\CodeVerifier;
use BlobSolutions\VcrAm\PairingClient;

$pairing = new PairingClient(integration: 'my-store/1.0');

// 1. Before you redirect. Persist both against this merchant's session.
$verifier = CodeVerifier::generate();
$state = bin2hex(random_bytes(16));

$request = $pairing->registerRequest(new RegisterPairingRequestInput(
    redirectUri: 'https://shop.example/wp-admin/admin.php?page=vcr-am',
    storeName: 'Example Shop',
    state: $state,
    codeChallenge: CodeVerifier::challengeFor($verifier),
));

// 2. Send the merchant's browser to $request->connectUrl.

// 3. They return to your redirectUri with `code` and `state`.
//    Check `state` against what you stored, then:
$paired = $pairing->exchangeCode($code, $verifier);

// Store this now. It is shown once and is never retrievable again.
$apiKey = $paired->apiKey;

echo "Paired with {$paired->registerName} (VCR {$paired->vcrId})\n";

Both calls run on your server. The verifier is what proves the exchange is you, so it must never reach the merchant's machine — a code intercepted in the redirect is worthless without it.

Three things worth knowing:

  • state is yours, and the SDK does not generate it. It comes back untouched on the redirect and is how you tell your own pairing from a replayed or forged one, which means it has to be checked against something you persisted.
  • redirectUri is shown to the merchant before they approve, so make it a page they would recognise as their own shop. It must be absolute https (plain http only on loopback), with no fragment and no credentials.
  • Every exchange failure returns one identical message. Unknown, already used, expired and wrong-verifier are deliberately indistinguishable, so a stolen code cannot be probed for liveness. Do not branch on the text.

The returned key is register-wide and does not expire for two years; $paired->expiresAt is when it does. $paired->crn is null for a register that has not finished activating with the tax service, which is worth showing the merchant — calls with that key will be refused until it has.

Endpoints

listCashiers(): list<CashierListItem>

Returns every cashier registered for the account, with a per-language map of names.

$cashiers = $client->listCashiers();

foreach ($cashiers as $cashier) {
    $hyName = $cashier->name['hy']->content ?? '(no Armenian name)';
    echo "{$cashier->deskId} (#{$cashier->internalId}): {$hyName}\n";
}

createCashier(CreateCashierInput $input): CreateCashierResponse

Provisions a new cashier under the account. The password is the terminal PIN (4–8 digits) — held in a private property and redacted from var_dump / print_r.

use BlobSolutions\VcrAm\Input\CreateCashierInput;
use BlobSolutions\VcrAm\Input\LocalizedName;
use BlobSolutions\VcrAm\LocalizationStrategy;

$response = $client->createCashier(new CreateCashierInput(
    name: new LocalizedName(
        value: ['hy' => 'Աննա Գրիգորյան', 'en' => 'Anna Grigoryan'],
        localizationStrategy: LocalizationStrategy::Transliteration,
    ),
    password: '1234',
));

echo "Cashier #{$response->id} provisioned (deskId={$response->deskId})\n";

createDepartment(CreateDepartmentInput $input): CreateDepartmentResponse

Creates a department (tax-regime-bound bucket of offers).

The department, not the offer, decides the tax regime printed on the receipt. Every register is created with one department per regime — VAT, VAT-exempt, turnover tax, micro-enterprise — numbered in that order, which makes new Department(1) the VAT one on every register whether or not the merchant owes VAT. Call listDepartments() once, read taxRegime, and set the offer's defaultDepartment to the id that matches how the business is actually registered; sale items then leave department out and inherit it. Nothing rejects a sale booked under the wrong regime, and a fiscal receipt can only be refunded and reissued, never corrected.

use BlobSolutions\VcrAm\Input\CreateDepartmentInput;
use BlobSolutions\VcrAm\Input\LocalizedName;
use BlobSolutions\VcrAm\LocalizationStrategy;
use BlobSolutions\VcrAm\TaxRegime;

$response = $client->createDepartment(new CreateDepartmentInput(
    taxRegime: TaxRegime::Vat,
    title: new LocalizedName(
        value: ['hy' => 'Հացաբուլկեղեն', 'ru' => 'Хлебобулочные', 'en' => 'Bakery'],
        localizationStrategy: LocalizationStrategy::Translation,
    ),
    externalId: 'erp-dept-bakery',
));

echo "Department #{$response->department} created — {$response->message}\n";

createOffer(CreateOfferInput $input): CreateOfferResponse

Adds a standalone offer (product or service) to the account catalogue.

use BlobSolutions\VcrAm\Input\CreateOfferInput;
use BlobSolutions\VcrAm\Input\Department;
use BlobSolutions\VcrAm\Input\LocalizedName;
use BlobSolutions\VcrAm\LocalizationStrategy;
use BlobSolutions\VcrAm\OfferType;
use BlobSolutions\VcrAm\Unit;

$response = $client->createOffer(new CreateOfferInput(
    type: OfferType::Product,
    classifierCode: '01.01.01',
    title: new LocalizedName(
        value: ['hy' => 'Հաց', 'en' => 'Bread'],
        localizationStrategy: LocalizationStrategy::Translation,
    ),
    defaultMeasureUnit: Unit::Piece,
    defaultDepartment: new Department(5),
    externalId: 'sku-bread',
));

echo "Offer #{$response->offerId} created\n";

listOffers(?string $externalId, ?OfferType $type, bool $includeArchived): list<OfferListItem>

Reads the offer catalogue (up to 500 rows). Handy for checking whether an offer already exists (by externalId) before creating it. Archived offers are excluded unless $includeArchived is true.

use BlobSolutions\VcrAm\OfferType;

$existing = $client->listOffers(externalId: 'sku-bread');
if ($existing === []) {
    // ... create it
}

$products = $client->listOffers(type: OfferType::Product);

getOffer(int $offerId): OfferListItem

Reads a single offer by its internal numeric id.

$offer = $client->getOffer(7);
echo "{$offer->id}: {$offer->classifierCode} ({$offer->defaultMeasureUnit})\n";

updateOffer(int $offerId, OfferTitle $title): OfferListItem

Renames an offer's title. Affects only future receipts — already-issued receipts keep the title they were created with, and the SRC fiscal record is unchanged.

use BlobSolutions\VcrAm\Input\OfferTitle;
use BlobSolutions\VcrAm\LocalizationStrategy;

$offer = $client->updateOffer(7, OfferTitle::localized(
    ['hy' => 'Լատտե', 'en' => 'Latte'],
    LocalizationStrategy::Translation,
));

searchClassifier(string $query, OfferType $type, Language $language): list<ClassifierSearchItem>

Fuzzy-searches the SRC classifier taxonomy. Useful for populating an autocomplete in an offer-creation UI. Language::Multi is rejected — pick a concrete language.

use BlobSolutions\VcrAm\Language;
use BlobSolutions\VcrAm\OfferType;

$matches = $client->searchClassifier('bread', OfferType::Product, Language::English);

foreach ($matches as $match) {
    echo "{$match->code} — {$match->title}\n";
}

registerSale(RegisterSaleInput $input): RegisterSaleResponse

Fiscalizes a sale. See Quick start above for the basic shape. A more elaborate example with a business buyer, an inline new offer, and an emailed receipt:

use BlobSolutions\VcrAm\Input\Buyer;
use BlobSolutions\VcrAm\Input\CashierId;
use BlobSolutions\VcrAm\Input\Department;
use BlobSolutions\VcrAm\Input\Offer;
use BlobSolutions\VcrAm\Input\OfferTitle;
use BlobSolutions\VcrAm\Input\RegisterSaleInput;
use BlobSolutions\VcrAm\Input\SaleAmount;
use BlobSolutions\VcrAm\Input\SaleItem;
use BlobSolutions\VcrAm\Input\SendReceiptToBuyer;
use BlobSolutions\VcrAm\Language;
use BlobSolutions\VcrAm\LocalizationStrategy;
use BlobSolutions\VcrAm\OfferType;
use BlobSolutions\VcrAm\Unit;

$response = $client->registerSale(new RegisterSaleInput(
    cashier: CashierId::byInternalId(42),
    items: [
        new SaleItem(
            offer: Offer::createNew(
                externalId: 'consult-1h',
                title: OfferTitle::localized(
                    content: ['hy' => 'Խորհրդատվություն, 1 ժամ', 'en' => 'Consultation, 1 hour'],
                    localizationStrategy: LocalizationStrategy::Translation,
                ),
                type: OfferType::Service,
                classifierCode: '70.22.11',
                defaultMeasureUnit: Unit::Hour,
                defaultDepartment: new Department(2),
            ),
            quantity: '1',
            price: '50000',
            unit: Unit::Hour,
        ),
    ],
    amount: new SaleAmount(nonCash: '50000'),
    buyer: Buyer::businessEntity(
        '01234567',
        new SendReceiptToBuyer('billing@example.am', Language::Armenian),
    ),
));

Payment: amount or autoSettle. A sale settles by exactly one of these (the constructor enforces it):

  • amount: new SaleAmount(...) — explicit AMD per tender, as above.
  • autoSettle: new AutoSettle(AutoSettleTender::Cash) — the VCR computes the AMD cart total and charges the whole of it to that one tender. Zero-tap, and the only sane option for a foreign-currency sale (you can't know the AMD total up front). Prefer the RegisterSaleInput::withAmount() / withAutoSettle() named constructors for a clear call site.

Foreign-currency sales (HO-234-N). Set currency on each item and price that item in the foreign currency; the VCR converts every line to AMD server-side at the previous-business-day CBA rate. The fiscal receipt is always AMD. All foreign-priced items in one sale must share the same currency, and mixing AMD with foreign lines is rejected. Preview the rate with getExchangeRate().

use BlobSolutions\VcrAm\AutoSettleTender;
use BlobSolutions\VcrAm\Input\AutoSettle;
use BlobSolutions\VcrAm\Input\Buyer;
use BlobSolutions\VcrAm\Input\CashierId;
use BlobSolutions\VcrAm\Input\Offer;
use BlobSolutions\VcrAm\Input\RegisterSaleInput;
use BlobSolutions\VcrAm\Input\SaleItem;
use BlobSolutions\VcrAm\Unit;

$response = $client->registerSale(RegisterSaleInput::withAutoSettle(
    cashier: CashierId::byInternalId(42),
    items: [
        new SaleItem(
            offer: Offer::existing('sku-usd'),
            quantity: '1',
            price: '10',        // priced in USD
            unit: Unit::Piece,
            currency: 'USD',    // VCR converts to AMD at the CBA rate
        ),
    ],
    autoSettle: new AutoSettle(AutoSettleTender::Cash),
    buyer: Buyer::individual(),
));

getSale(int $saleId): SaleDetail

Reads back a previously-registered sale, including all items, refunds, and the cashier snapshot.

$sale = $client->getSale(4711);

echo "Sale #{$sale->id} — total cash {$sale->cashAmount}\n";

foreach ($sale->items as $item) {
    echo "  - srcId={$item->srcId} qty={$item->quantity} @ {$item->price}\n";
}

foreach ($sale->refunds as $refund) {
    echo "  refund: cash={$refund->cashAmount}\n";
}

registerSaleRefund(RegisterSaleRefundInput $input): RegisterSaleRefundResponse

Refunds a previously-registered sale — full or partial.

Full refund (omit items):

use BlobSolutions\VcrAm\Input\CashierId;
use BlobSolutions\VcrAm\Input\RegisterSaleRefundInput;
use BlobSolutions\VcrAm\RefundReason;

$response = $client->registerSaleRefund(new RegisterSaleRefundInput(
    cashier: CashierId::byDeskId('desk-1'),
    saleId: 4711,
    reason: RefundReason::CustomerRequest,
));

Partial refund by srcId of a specific sale item:

use BlobSolutions\VcrAm\Input\RefundAmount;
use BlobSolutions\VcrAm\Input\RefundItemInput;

$response = $client->registerSaleRefund(new RegisterSaleRefundInput(
    cashier: CashierId::byDeskId('desk-1'),
    saleId: 4711,
    reason: RefundReason::DefectiveGoods,
    reasonNote: 'Mouldy crust on one loaf',
    refundAmounts: new RefundAmount(cash: '750'),
    items: [
        new RefundItemInput(srcId: 999_001, quantity: '1'),
    ],
));

registerPrepayment(RegisterPrepaymentInput $input): RegisterPrepaymentResponse

Registers an advance payment from a buyer that will be redeemed against a future sale. Has no items — a prepayment is a single sum, not a basket.

use BlobSolutions\VcrAm\Input\Buyer;
use BlobSolutions\VcrAm\Input\CashierId;
use BlobSolutions\VcrAm\Input\PrepaymentAmount;
use BlobSolutions\VcrAm\Input\RegisterPrepaymentInput;

$response = $client->registerPrepayment(new RegisterPrepaymentInput(
    cashier: CashierId::byDeskId('desk-1'),
    amount: new PrepaymentAmount(nonCash: '20000'),
    buyer: Buyer::individual(),
));

echo "Prepayment #{$response->prepaymentId}, fiscal={$response->fiscal}\n";

crn and fiscal may be null if SRC fiscal issuance is pending — the prepayment is recorded either way and the SRC handshake retries asynchronously.

getPrepayment(int $prepaymentId): PrepaymentDetail

Reads back a registered prepayment, including its (at most one) refund.

$prepayment = $client->getPrepayment(9001);

echo "Prepayment #{$prepayment->id} — cash={$prepayment->cashAmount}\n";

if ($prepayment->refund !== null) {
    echo "  refunded: cash={$prepayment->refund->cashAmount}\n";
}

registerPrepaymentRefund(RegisterPrepaymentRefundInput $input): RegisterPrepaymentRefundResponse

Refunds a prepayment in full. There is no partial-refund variant — a prepayment is indivisible.

use BlobSolutions\VcrAm\Input\CashierId;
use BlobSolutions\VcrAm\Input\RegisterPrepaymentRefundInput;
use BlobSolutions\VcrAm\RefundReason;

$response = $client->registerPrepaymentRefund(new RegisterPrepaymentRefundInput(
    cashier: CashierId::byDeskId('desk-1'),
    prepaymentId: 9001,
    reason: RefundReason::CustomerRequest,
));

getExchangeRate(string $currency): ExchangeRate

Previews the AMD conversion rate the VCR would apply to a foreign-currency sale registered now — the CBA mid-market rate published on the previous business day (HO-234-N). Read-only; it fiscalizes nothing. $currency is a 3-letter ISO 4217 code (case-insensitive); AMD is rejected server-side.

$rate = $client->getExchangeRate('USD');

$amdEquivalent = (float) $usdPrice * $rate->ratePerUnit;
echo "1 USD = {$rate->ratePerUnit} AMD (rate date {$rate->rateDate})\n";

Error handling

Every call can raise one of three exceptions, all extending the abstract BlobSolutions\VcrAm\Exception\VcrException. Catch the base class to handle any SDK-level failure, or branch on the concrete subclass when you care about the cause:

use BlobSolutions\VcrAm\Exception\VcrApiException;
use BlobSolutions\VcrAm\Exception\VcrNetworkException;
use BlobSolutions\VcrAm\Exception\VcrValidationException;

try {
    $response = $client->registerSale($input);
} catch (VcrApiException $e) {
    // Server returned a non-2xx HTTP response. The API's error envelope is
    // `{ error, issues?, requestId?, pending? }` and all of it is surfaced:
    // $e->statusCode       — HTTP status (int)
    // $e->apiErrorMessage  — the envelope's `error` (string|null)
    // $e->issues           — list<ApiErrorIssue>, present on validation failures
    // $e->requestId        — correlation id on unexpected 5xx (string|null)
    // $e->pending          — PendingResource|null, see below
    // $e->rawBody          — full response body, in case envelope parsing failed
    // $e->request          — PSR-7 request (X-API-Key header redacted)
    // $e->response         — PSR-7 response

    // A 502 does NOT always mean nothing happened. When the tax service was
    // merely unreachable, VCR saved the document and queued it for automatic
    // resubmission — resending would fiscalize a SECOND receipt, and a fiscal
    // receipt cannot be deleted, only refunded.
    if ($e->pending !== null) {
        $queue->recordPending($e->pending->type, $e->pending->id);

        return; // nothing was lost; read it back later instead of resending
    }

    foreach ($e->issues as $issue) {
        $errors[$issue->pointer()] = $issue->message; // e.g. 'items.0.price'
    }

    throw $e;
} catch (VcrNetworkException $e) {
    // DNS / TLS / TCP / timeout failure before any HTTP response.
    // $e->getPrevious() is the underlying PSR-18 ClientExceptionInterface.
    // $e->request — PSR-7 request (X-API-Key redacted)
    // Safe to retry after a backoff, but be careful with non-idempotent endpoints.
    throw $e;
} catch (VcrValidationException $e) {
    // Server returned 2xx but the body did not match the expected schema.
    // Indicates a server-side wire-format change or upstream proxy corruption.
    // $e->detail   — Valinor mapping error or 'response body is not valid JSON: …'
    // $e->rawBody  — full response body
    // This is never a caller-side bug. File an issue.
    throw $e;
}

The X-API-Key header is stripped from the request preserved on every exception. APMs that auto-serialize exception state (Sentry, Bugsnag, Laravel's verbose handler) will not surface the API key in their breadcrumbs.

Configuration

use BlobSolutions\VcrAm\VcrClient;

$client = new VcrClient(
    apiKey: $apiKey,
    baseUrl: VcrClient::DEFAULT_BASE_URL,  // 'https://vcr.am/api/v1'
    httpClient: $myPsr18Client,            // optional — falls back to discovery
    requestFactory: $myPsr17Factory,       // optional — falls back to discovery
    streamFactory: $myPsr17Factory,        // optional — falls back to discovery
    logger: $myPsr3Logger,                 // optional — defaults to NullLogger
    integration: 'my-plugin/1.2.3',        // optional — see below
);

Naming your integration

If this SDK ships inside something with a name of its own — a plugin, an app, a hosted product — say so. The token is appended to the SDK's own, and it is what lets a support question about your integration be answered from the server's request log:

$client = new VcrClient(
    apiKey: $apiKey,
    integration: sprintf('my-plugin/%s (WordPress/%s; PHP/%s)', $pluginVersion, $wpVersion, PHP_VERSION),
);

// User-Agent: vcr-am-sdk-php/0.10.0 (+https://github.com/blob-am/vcr-am-sdk-php) my-plugin/1.2.3 (WordPress/7.1; PHP/8.4.2)

Printable ASCII, 1-200 characters, validated when the client is constructed: the value ends up in a header, and a version string assembled from platform metadata is not something to trust blindly.

PSR-3 logger

The SDK emits structured log lines for outbound requests, network failures, and 4xx/5xx responses. Pass any PSR-3 logger to capture them:

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$logger = new Logger('vcr-am');
$logger->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG));

$client = new VcrClient(apiKey: $apiKey, logger: $logger);

Channels emitted:

Level Message Context
debug VCR.AM request method, url
warning VCR.AM network failure method, url, error
warning VCR.AM API error method, url, status, errorCode, errorMessage, rawBodyPreview (≤500 bytes)

The X-API-Key header is never logged.

PSR-18 HTTP client

The SDK auto-discovers a PSR-18 client via php-http/discovery (the bundled Guzzle 7 by default). To override — most commonly, to set timeouts or attach middleware — construct your own and inject it:

use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\RequestOptions;

$stack = HandlerStack::create();
$stack->push(Middleware::retry(
    fn ($retries, $req, $res, $err) => $retries < 2 && $err !== null,
    fn ($retries) => 1000 * (2 ** $retries),
));

$http = new Guzzle([
    RequestOptions::CONNECT_TIMEOUT => 5,
    RequestOptions::TIMEOUT         => 30,
    'handler'                       => $stack,
]);

$client = new VcrClient(apiKey: $apiKey, httpClient: $http);

Any PSR-18 client works — Symfony's symfony/http-client, Laravel's Illuminate\Http\Client\Factory (with a small adapter), php-http/curl-client, etc.

PSR-17 factories

The same php-http/discovery mechanism finds a PSR-17 request/stream factory (by default nyholm/psr7's Psr17Factory). Override only if you need to keep a single PSR-17 implementation across your application — the SDK does not care which one it is, as long as it conforms to the standard.

Timeouts

PSR-18 has no portable per-request timeout primitive, so the SDK does not expose one. Configure the timeout on the HTTP client you pass in (or on the auto-discovered Guzzle instance via the bundled guzzlehttp/guzzle dependency):

use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\RequestOptions;

$http = new Guzzle([
    RequestOptions::CONNECT_TIMEOUT => 5,   // seconds
    RequestOptions::TIMEOUT         => 30,  // seconds, total
]);

$client = new VcrClient(apiKey: $apiKey, httpClient: $http);

A bounded timeout is strongly recommended for fiscalization endpoints — an indefinite hang on a stalled SRC backend will block the calling thread.

Monetary precision

Decimal fields on request types (SaleAmount::$cash, SaleItem::$price, etc.) are typed as string to preserve full precision over the wire. Only digits and at most one decimal point are accepted — no sign, no scientific notation. Constructor-time validation enforces the shape.

Decimal fields on response types (SaleDetail::$cashAmount, Receipt::$total, etc.) are typed as PHP float because the API serialises them as JSON numbers (Prisma Float columns). This mirrors the TypeScript SDK's z.number() shape. Do not use these floats for reconciliation arithmetic — re-fetch the receipt or compute against your own ledger of decimal-strings sent in. Equality comparisons on these floats are unsafe.

Idempotency and retries

A fiscal receipt cannot be corrected, only refunded and reissued, so a retry that files a second one is the most expensive mistake this SDK can help you make. Pass an idempotency key and a repeat of the same call returns the original receipt instead:

$sale = $client->registerSale($input, $order->fiscalizationKey);

The key is the second argument on registerSale(), registerSaleRefund(), registerPrepayment() and registerPrepaymentRefund(). It travels as the Idempotency-Key header, stays replayable for 30 days, and is scoped per endpoint and per register — so the same value on /sales and on /sales/refund is two independent keys.

Generate it once per intended fiscal operation and persist it with the order before the first call, then reuse it verbatim for every retry. A fresh value per attempt protects nothing, and the order number on its own is too coarse when one order can produce several fiscal documents. For a stateless webhook handler that may be delivered twice, derive it instead:

$key = Uuid::uuid5(Uuid::fromString(self::NAMESPACE), "{$order->id}:sale")->toString();

Both deliveries then compute the same key and the second one replays. Do not derive it from the payment provider's event id — a different event about the same order would produce a different key and fiscalize again.

Server responses to a replayed request are byte-identical to the original and carry Idempotent-Replay: true. Reusing a key with a different body answers 422; reusing one while the first request is still in flight answers 409, which means wait and retry with the same key rather than minting a new one. A body the server rejected on validation spends no key — the request never reached the operation, so correct the field and retry with the same key.

Without a key, a retried request files a second fiscal document. The SDK does not retry automatically for that reason. If you add retries, attach a Guzzle (or other PSR-18) middleware that retries only on VcrNetworkException-class failures (DNS/TLS/timeout) and never on a VcrApiException — the latter means the server has already seen and rejected your request — and always send the key.

Compatibility

SDK version PHP versions tested
^0.x 8.2, 8.3, 8.4, 8.5

Development

composer install
composer check    # format check + phpstan + tests
composer format   # apply Pint fixes

CI enforces pint --test, PHPStan max + strict + deprecation rules + phpunit extension, and a 100% line-coverage gate on PHP 8.5.

License

ISC © Alex Kraiz, Blob Solutions