blob-solutions/vcr-am-sdk
Composer 安装命令:
composer require blob-solutions/vcr-am-sdk
包简介
Official PHP SDK for the VCR.AM Virtual Cash Register API — fiscalize sales, refunds, and prepayments through Armenia's State Revenue Committee.
关键字:
README 文档
README
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 until1.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.1or 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\Department; 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'), department: new Department(5), 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).
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).
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), ), department: 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 theRegisterSaleInput::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\Department; 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'), department: new Department(2), 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. // $e->statusCode — HTTP status (int) // $e->apiErrorCode — SRC error code (string|null), e.g. 'INVALID_TIN' // $e->apiErrorMessage — human-readable detail (string|null) // $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 if ($e->statusCode === 422 && $e->apiErrorCode === 'INVALID_TIN') { // Surface a friendly message to the cashier UI } 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 );
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
The SDK does not retry failed requests automatically. Fiscalization endpoints are not guaranteed to be idempotent on the server side; a silent retry could double-register a sale.
If you need 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.
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
blob-solutions/vcr-am-sdk 适用场景与选型建议
blob-solutions/vcr-am-sdk 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 150 次下载、GitHub Stars 达 0, 最近一次更新时间为 2026 年 05 月 01 日, 在 PHP 生态内属于活跃度较高的组件。
它主要适用于以下技术方向: 「php」 「sdk」 「armenian」 「armenia」 「src」 「fiscal」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。
我们在过去多个企业项目中使用过 blob-solutions/vcr-am-sdk 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。
基于 blob-solutions/vcr-am-sdk 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。
线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。
承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。
与 blob-solutions/vcr-am-sdk 相关的其它包
同方向 / 同关键字的高下载量 PHP Composer 包推荐,方便对比选型:
Alfabank REST API integration
Convert numbers to words in Armenian, Russian, and English for Laravel
Zero-dependency raw PHP DNS resolver, domain-ownership verification, and intoDNS/MxToolbox-style diagnostics. Queries authoritative nameservers directly over sockets — never trusts the recursive cache for ownership checks.
Armenian language pack for Flarum.
A lightweight plain-PHP framework for database-backed CRUD APIs.
bughq error tracking - PHP SDK
统计信息
- 总下载量: 150
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 44
- 依赖项目数: 1
- 推荐数: 0
其他信息
- 授权协议: ISC
- 更新时间: 2026-05-01