davit-vardanyan / ameriabank-vpos-php
Unofficial PHP client for the Ameriabank vPOS 3.1 payment gateway.
Package info
github.com/davit-vardanyan/ameriabank-vpos-php
pkg:composer/davit-vardanyan/ameriabank-vpos-php
Requires
- php: ^8.3
- ext-json: *
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.45
- friendsofphp/php-cs-fixer: ^3.75
- guzzlehttp/guzzle: ^7.9
- infection/infection: ^0.35
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.6
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.2
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5
- rector/rector: ^2.0
- roave/backward-compatibility-check: ^8.0
Suggests
- guzzlehttp/guzzle: PSR-18 HTTP client implementation
- symfony/http-client: PSR-18 HTTP client implementation
README
This is an unofficial client. It is not affiliated with, endorsed by, or supported by Ameriabank.
ameriabank-vpos-php
A framework-agnostic PHP client for the Ameriabank vPOS 3.1 payment gateway.
Status: pre-release. Nothing has been tagged yet. The enums, the
AmountandResponseCodevalue objects, the request and response DTOs, the PSR-18 transport, the client surface that ties them together —Vpos::payments()and its siblings — and the BackURL callback type with the verification round trip it forces are all in place, so the REST payment lifecycle is usable end to end. Because nothing is tagged, the public API is not yet stable. SOAP reporting andSSNCheckare not implemented, and this README does not document methods that do not exist.
Requirements
- PHP
^8.3 ext-json- A PSR-18 HTTP client (see below)
Installation
composer require davit-vardanyan/ameriabank-vpos-php
You must install a PSR-18 HTTP client separately
This package is PSR-18 abstract: it depends on the PSR-18, PSR-17, PSR-7, and PSR-3 interfaces, and never on a concrete HTTP implementation. No HTTP client is pulled in for you, so that the package does not force a second copy of one into an application that already has its own.
You supply the client. Either install one that
php-http/discovery can find
automatically:
composer require guzzlehttp/guzzle
# or
composer require symfony/http-client nyholm/psr7
…or hand the client your application already has to Vpos's httpClient
argument, which accepts any PSR-18 implementation. Leave it null and discovery
runs instead; if discovery finds nothing, you get a ConfigurationException
rather than a bare php-http/discovery error.
guzzlehttp/guzzle and symfony/http-client appear under suggest in
composer.json. They are never in require.
Timeouts belong to the client you inject
PSR-18 has no timeout API. Its whole surface is one method, sendRequest(),
and there is nowhere in it to say how long the caller is willing to wait. This
package therefore cannot bound a request portably, and deliberately does not
try: there is no timeout option on any class here, and adding one would mean
depending on a concrete HTTP implementation, which this package does not do.
The client is yours, so the timeout is yours. Set it when you build the client, before you hand it over.
With Guzzle:
use GuzzleHttp\Client; $httpClient = new Client([ 'connect_timeout' => 3.0, // seconds allowed to establish the connection 'timeout' => 10.0, // seconds allowed for the whole request ]);
With Symfony's HTTP client:
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; $httpClient = new Psr18Client(HttpClient::create([ 'timeout' => 10.0, // seconds of inactivity before the connection drops 'max_duration' => 15.0, // seconds allowed for the whole request ]));
Both of those are configuration for software you chose. Neither package is a
dependency of this one — they are suggest entries and nothing more — and any
other PSR-18 implementation works the same way, with whatever option names it
happens to use.
Timeout in InitPaymentRequest is a different thing entirely
InitPaymentRequest takes a timeout. It is not an HTTP timeout. It is a
gateway-side session length in seconds — how long the payment page stays valid
for the customer after the payment is registered — and it bounds nothing about
the HTTP request that registers it:
use DavitVardanyan\AmeriabankVpos\Enum\Currency; use DavitVardanyan\AmeriabankVpos\Money\Amount; use DavitVardanyan\AmeriabankVpos\Request\InitPaymentRequest; $request = new InitPaymentRequest( amount: Amount::fromDecimalString('10.00', Currency::AMD), orderId: 1001, backUrl: 'https://merchant.example/vpos/return', timeout: 900, // the payment page stays valid for 15 minutes );
Setting one does not substitute for the other, in either direction. A
timeout: 900 on an injected client with no timeout of its own is a request
that can hang indefinitely while offering the customer a quarter of an hour to
pay.
This library validates the value to 1..1200 seconds and throws
ValidationException outside that range. The gateway does not validate it at
all — 1201, 0 and −1 were each accepted silently on the sandbox — so without the
client-side check a typo would become a payment page that expires at the wrong
moment rather than an error.
Why the timeout you choose is a real decision
Four operations move money and are never retried: ConfirmPayment,
RefundPayment, CancelPayment and MakeBindingPayment. A timeout is a
transport failure, and when one of these fails in transport this library cannot
know whether the gateway acted, so it throws IndeterminateStateException after
exactly one attempt. That exception tells the caller to reconcile through
GetPaymentDetails and not to retry, and it is deliberately not a subtype of
TransportException, so a catch written to retry transport failures cannot
swallow it.
A timeout set too short therefore does not merely fail a request. It can turn a
capture or a refund the gateway may well have completed into work a human has to
reconcile. The read-only operations and InitPayment are retried automatically
instead — InitPayment only ever with the byte-identical body it first sent —
so the cost of a short timeout is not the same across operations.
The one completed sandbox payment behind this package puts numbers on that, and
they do not fall the way "writes are slower than reads" would suggest. A
rejected write answers at read speed: both refusals on record — an
over-refund and a cancel that the payment's state forbade — came back in about
102 ms, faster than every GetPaymentDetails call in the same run, which took
145–174 ms. InitPayment took 61 ms. The two refunds that actually moved money
took 518 ms and 910 ms. The cost is in the settlement, not in the verb, so the
slowest operations are exactly the ones this package will never retry: a timeout
short enough to cut one of them off buys you an IndeterminateStateException
and a manual reconciliation, not a second attempt. Those are one sandbox and one
payment — read them as orders of magnitude rather than as a budget
(CONVENTIONS.md §4.21).
Usage
Vpos is the entry point and the only class you construct. It owns one
transport and exposes three operation clients — payments(), bindings() and
reports() — plus paymentPageUrl(), which performs no API call.
use DavitVardanyan\AmeriabankVpos\Config\Credentials; use DavitVardanyan\AmeriabankVpos\Config\Environment; use DavitVardanyan\AmeriabankVpos\Vpos; $vpos = new Vpos( credentials: new Credentials( clientId: (string) getenv('AMERIA_CLIENT_ID'), username: (string) getenv('AMERIA_USERNAME'), password: (string) getenv('AMERIA_PASSWORD'), ), environment: Environment::Test, ); Vpos::PROTOCOL_VERSION; // '3.1' — the wire protocol this client targets
Never hard-code credentials in source. Read ClientID, username and password
from your environment or secret store, as above. A blank one throws
ConfigurationException at construction, rather than reaching the gateway and
coming back as something that looks like a wrong password.
environment is required and has no default, deliberately. Defaulting to Test
means a misconfigured deployment silently takes no money and finds out at
reconciliation weeks later; defaulting to Production means a developer who
forgets it hits live infrastructure. Naming it has neither failure mode.
The payment lifecycle
Four steps: register the order, send the customer to the payment page, ask the gateway what happened, and — if you have to — give the money back.
1. Register the order. InitPayment returns the PaymentID the payment
page is keyed on.
use DavitVardanyan\AmeriabankVpos\Enum\Currency; use DavitVardanyan\AmeriabankVpos\Money\Amount; use DavitVardanyan\AmeriabankVpos\Request\InitPaymentRequest; $init = $vpos->payments()->init(new InitPaymentRequest( amount: Amount::fromMinorUnits(1000, Currency::AMD), // 10.00 AMD orderId: 1001, // your own order number backUrl: 'https://merchant.example/vpos/return', description: 'Order 1001', opaque: 'cart-9f3c', // echoed back to you untouched timeout: 900, // payment page valid for 15 minutes )); $paymentId = $init->paymentId ?? '';
Set Description on the request, and read trxnDescription back off the
response. The symmetry you expect is not there: GetPaymentDetails returns
your submitted text in TrxnDescription and overwrites the response's own
Description with the processor's wording — Approved. - Payment post authorized on a deposited payment, Approved. - Refunded payment back to client card once a refund has run. $details->description is the gateway talking, not
you (CONVENTIONS.md §4.15).
That echo is not byte-exact, so never use a non-ASCII Description as a
reconciliation key. An Armenian Description came back from
TrxnDescription with every Armenian codepoint replaced by ¿ (U+00BF); the
codepoint count and the ASCII prefix survived, the letters did not. Reconcile on
OrderID, or on an ASCII value you put in Opaque, and treat
trxnDescription as text for a human to read rather than a value to compare.
Scope this exactly as it was observed: Armenian only, Description →
TrxnDescription only, on the test environment. Russian and other scripts are
untested, and the request itself is accepted — the package sends your text as raw
UTF-8 and the gateway takes it. The question of why the value comes back altered
is with the bank (CONVENTIONS.md §4.15, §13).
Amount holds an integer minor-unit count and a Currency; there is no
constructor taking a float, and none will be added. Build one with
Amount::fromMinorUnits(1000, Currency::AMD) or
Amount::fromDecimalString('10.00', Currency::AMD). Only Currency::AMD has
ever been accepted by the gateway — see Unverified behaviour.
2. Send the customer to the payment page.
use DavitVardanyan\AmeriabankVpos\Enum\Language; header('Location: ' . $vpos->paymentPageUrl($paymentId, Language::Armenian), true, 303);
paymentPageUrl() refuses a blank PaymentID rather than building a broken
page. That matters because a failed InitPayment answers with "PaymentID": ""
— an empty string, never null — so a caller who reached this line holding
nothing gets an exception instead of a customer staring at an error page.
3. Read the outcome — from the gateway, never from the callback.
The customer comes back to your backUrl with a query string like:
?orderID=1001&resposneCode=00&paymentID=1234abcd-…&opaque=cart-9f3c&description=…
Those parameters are unsigned. Never trust them. There is no HMAC, no
signature and no shared secret anywhere in this callback, so anyone who can type
a URL can send you resposneCode=00 for an order nobody paid for. The library
gives you no way to read a status out of them, because there is no safe way to
read one.
Treat the callback as a notification that something happened, take its identifiers as lookup keys and nothing more, and ask the gateway:
use DavitVardanyan\AmeriabankVpos\Callback\VposCallback; use DavitVardanyan\AmeriabankVpos\Exception\VposExceptionInterface; try { $callback = VposCallback::fromQuery($_GET); // unsigned, forgeable, identifiers only $details = $vpos->verify($callback); // the round trip that actually answers } catch (VposExceptionInterface $e) { // An unusable callback, an OrderID that is not the callback's, or a gateway // that would not answer. The payment is unconfirmed: reconcile it later and // release nothing now. This path is not the exception — see below. return; } $details->orderId; // ?string — the order the gateway says this payment is for $details->orderStatusRaw; // ?string, exactly as the gateway sent it $details->orderStatus; // ?OrderStatus — null when this SDK does not know the raw value $details->paymentStateRaw; // ?string — a second, separate status field, also populated $details->paymentState; // ?PaymentState — null when this SDK does not know the raw value $details->approvedAmountRaw; // ?string, the authorised total — the amount to compare against yours $details->depositedAmountRaw; // ?string, the remaining refundable balance, not the captured total $details->refundedAmountRaw; // ?string, refunded so far — accumulates across partial refunds $details->approvedAmount; // ?Amount — typed companion; null when no currency or a zero value $details->trxnDescription; // ?string — the Description *you* submitted $details->description; // ?string — the processor's own wording, not yours $details->rrn; // ?string, the acquirer's retrieval reference number
Every amount arrives twice: a raw string exactly as the gateway sent it, and a
typed Amount beside it. The raw one is the field you can always read — the
typed one is null unless the same response also carried a currency and the
value is non-zero, so on a payment that has not been refunded refundedAmount
is null while refundedAmountRaw is "0.0".
rrn and mdOrderId came back byte-identical on every body observed, so
reconciling on both is reconciling on the same value twice
(CONVENTIONS.md §4.19).
VposCallback::fromServerRequest($request) does the same from a PSR-7
ServerRequestInterface, so no framework is assumed.
Either constructor throws ValidationException when paymentID or orderID is
missing or blank, because a callback with no identifiers has nothing to verify.
Both match the five parameter names exactly and case-sensitively — resposneCode
misspelling included, since that is the wire spelling — so a spelling or case
variant is not quietly absorbed.
The case of a value is pinned for the same reason, and it will surprise you
once. The lowercase paymentID in the query string above is not a typo:
InitPayment returns that identifier in uppercase and the callback echoes the
very same identifier in lowercase. This package normalises neither, because the
case a channel sends is part of that channel's wire format. Compare the two
case-insensitively, or compare orderID instead; a case-sensitive === between
them reports a mismatch that is not one (CONVENTIONS.md §4.12). The callback's
description needs the same care for a different reason: both successful
callbacks on record delivered Operation Approved with a trailing space,
handed back verbatim rather than trimmed. The second arrived byte-identical to
the first, trailing space included, on a different payment and a different
order — so the space is not a one-off oddity of a single redirect that a future
call might tidy up. Two observations is still two, but they agree. Log the
value; never compare against it.
verify() is details() plus one check you would otherwise have to write
yourself, and it is worth knowing which. The paymentID in that query string is
attacker-controlled like everything else in it, and a PaymentID is not a secret
— it was handed to the customer's browser — so somebody can put a genuine
paymentID from a stranger's paid order on your backUrl beside their own
orderID, and a bare details() call will answer, truthfully, that a payment
succeeded. verify() closes that: when the response names an OrderID, it must
be identical to the callback's or this throws ValidationException; and a
response naming a blank OrderID — empty or whitespace-only — is refused too,
for a distinct reason and with a distinct message, since nothing disagreed there
and there was simply no order identity to check against. The comparison itself is
exact — no trimming, no case folding, no numeric coercion, the one trim being the
blank test — because '1001' and '01001' are different orders.
That is one check of three, and it is the only one verify() makes. A call that
returns proves that the gateway answered, not that money moved — and on the
unhappy path it will often not return at all but throw, since every
GetPaymentDetails observation on record against a payment that never went
through is a failure
(see A failed operation throws).
So handle the throw, and before you treat anything as paid, all three of these
must hold:
- the order identity —
verify()does this one for you, and only when the response carries anOrderID. If that field comes back absent or null the check is skipped and you get no order-identity protection from it; if it comes back blank it is refused rather than skipped, so the three outcomes are match → pass, blank → refuse, absent → skip. Both completed payments on record produced the first of the three — a populatedOrderID, identical to the callback's, reaching the comparison and passing it, on the second one throughverify()itself — but that is two payments on one sandbox client, so keep your own record and compare$details->orderIdagainst it if you hold one; approvedAmountRawis the amount you asked for. Compare that one, notdepositedAmountRaw:DepositedAmountis the remaining refundable balance and decrements as refunds run, so it is correct before a refund and wrong after one, whileApprovedAmountis the authorised total and does not move (CONVENTIONS.md §4.16). Compare the raw string rather than the typedAmount: anAmountneeds a currency, and this SDK builds one only from the currency the same response carried, never from a default. The completed payment did carry one —"051"— so the typedamount,approvedAmountanddepositedAmountwere all populated on it;refundedAmountwas stillnull, because the constructor refuses a zero and nothing had been refunded yet. A value that is missing is not a match. Fail closed there too;orderStatusRaw(ororderStatus) is a status you positively recognise as paid — and anything you cannot positively recognise is not paid. Fail closed. The same goes forpaymentStateRaw: these are two separate fields, both populated, and both worth reading.
This library will not make that last check for you, because it cannot. One
completed sandbox payment carried OrderStatus "2" beside PaymentState
payment_deposited, and "4" beside payment_refunded after a refund — but
"4" appeared on a payment that was only partially refunded, with a balance
still outstanding, so the refunded status does not mean fully refunded and a
merchant who reads the status and skips the amounts above will get it wrong
(CONVENTIONS.md §4.18). One payment on one sandbox client is not the status
table for your account. Recognise the statuses you have watched settle in your
own account, and treat every other status as unpaid.
VposCallback exposes no success accessor, no status and no response code —
there is nothing there that could implement one honestly. It does keep the two
diagnostics, reachable only as $callback->untrustedDiagnostics(), an array
carrying resposneCode and description as they arrived. Log them on a failed
callback, where the gateway's error text is often the only clue you get. Do not
branch on them: both are attacker-controlled, and reading ['resposneCode'] to
decide anything is exactly the mistake the array shape exists to make visible at
the call site.
Every enum-typed field arrives in pairs — a raw value and a nullable enum. The
enum is null when the gateway sent something this SDK does not yet recognise,
which is a value the bank added without notice, not an error. Read the raw when
the enum is null; never assume the enum being null means the field was absent.
4. Refund, in full or in part.
$refund = $vpos->payments()->refund( $paymentId, Amount::fromMinorUnits(1000, Currency::AMD), ); $refund->responseCode->asString(); // '00'
Partial refunds accumulate, and the field that tells you what is left is
depositedAmountRaw on a fresh details() read — a payment of 10 refunded by 4
and then by 3 reported DepositedAmount 10, then 6, then 3, while
RefundedAmount went 0, then 4, then 7 and ApprovedAmount stayed at 10
throughout. Ask for more than the balance and the gateway refuses rather than
clamping: ResponseCode "07", Refund amount exceeds deposited amount,
raised here as ApiException.
Do not treat "07" as a single condition. A cancel() that the payment's state
forbids answers "07" as well, with Reversal is impossible for current transaction state. The two are told apart only by responseMessage(), which is
why no response code is mapped to a dedicated exception subclass and why this
package ships no code-to-description table: the vendor PDF calls 07 "System
Error", and a transcribed table would have printed that over the top of two
accurate messages the gateway had already sent (CONVENTIONS.md §4.17). The
success message varies by endpoint too — InitPayment answers "OK" and
RefundPayment answers "Success" — so read responseMessage() rather than
assuming a word.
confirm() captures an authorised payment and cancel() voids one before
capture; both take the same shape. None of the three is ever retried — see
below.
A failed operation throws; it does not return a code
Nothing above checks a response code, and that is deliberate. HTTP status carries no business meaning at this gateway — an authentication failure arrives as HTTP 200, and a well-formed request can come back as HTTP 500 — so this library decides success from the body and raises on anything else. A method that returns has succeeded; the response object is there to tell you the details, not whether it worked.
That is a claim about the call, not about the payment. On a read such as
details() or verify(), a return means the gateway answered the question you
asked — nothing more. The payment's own outcome is in the body, and reading it is
step 3's three checks, not this method's return.
Do not read the converse into it either: a query about a payment that never
completed does not reliably come back at all. Three response shapes have
been seen from GetPaymentDetails, and only one of them returns. Every lookup
made with valid credentials against a payment that had actually been paid
answered HTTP 200 with ResponseCode "00" and a fully populated body. Every
other observation is a failure, and so throws, in one of two shapes: HTTP 500
with the ASP.NET envelope {"Message":"An error has occurred."}, which surfaces
as GatewayFaultException, or HTTP 200 carrying ResponseCode "550", which
surfaces as ApiException. What separates those two failure shapes from each
other has never been explained
(see Unverified behaviour).
"550" in particular tells you less than it looks like it does, and that is
worth knowing before you debug one. As recorded in CONVENTIONS.md §4.25, it has
arrived for an order that was registered and never attempted, asked about with
correct credentials; and it has arrived for a payment that had completed,
asked about with a wrong password (case L5.3). Neither of those is the
meaning of the code — it is overloaded, exactly as "07" is (§4.17). Nor does
the message narrow it down, because there is none: this endpoint sends no
ResponseMessage at all, so the exception reads … failed with response code 550: (no message), and the only diagnostic text on the body is
Description "System Error", which a failure code leaves no response object to
read off. Suspect the credentials you sent as readily as the order you asked
about.
So expect the unhappy path to throw rather than to hand you a body to
inspect, and catch both — a thrown GatewayFaultException means the gateway
would not answer, which is evidence neither that the payment happened nor that
it did not, and on this endpoint it is not evidence about your credentials
either: a wrong password against an order that produces the fault produces the
fault, not a "550" (CONVENTIONS.md §4.26).
use DavitVardanyan\AmeriabankVpos\Exception\ApiException; use DavitVardanyan\AmeriabankVpos\Exception\IndeterminateStateException; use DavitVardanyan\AmeriabankVpos\Exception\VposExceptionInterface; try { $refund = $vpos->payments()->refund($paymentId, $amount); } catch (IndeterminateStateException $e) { // The exchange never completed, and the refund may or may not have happened. // Do not retry. Reconcile: $details = $vpos->payments()->details($e->paymentId() ?? $paymentId); } catch (ApiException $e) { // The gateway answered, and the answer was no. $e->responseCode(); // int|string, keeping the type the wire sent $e->responseMessage(); } catch (VposExceptionInterface $e) { // Everything else this package throws: a field rejected before dispatch, // a transport failure, a misconfigured client, a gateway fault, a body // that could not be mapped. }
catch (VposExceptionInterface) catches everything from this package and
nothing else. IndeterminateStateException is deliberately not a
TransportException, so a catch written to retry transport failures cannot
swallow the one case where retrying may refund twice.
Bindings and reporting
use DateTimeImmutable; use DavitVardanyan\AmeriabankVpos\Enum\PaymentType; $bindings = $vpos->bindings()->all(PaymentType::BindingMainRest); $vpos->bindings()->activate('card-holder-id', PaymentType::BindingMainRest); $vpos->bindings()->deactivate('card-holder-id', PaymentType::BindingMainRest); $vpos->bindings()->pay($makeBindingPaymentRequest); $pending = $vpos->reports()->pending( new DateTimeImmutable('-1 day'), new DateTimeImmutable('now'), );
bindings()->pay() is not a silent server-to-server charge: its response
carries AcsUrl, PaReq and TermUrl, a 3-D Secure challenge you must still
put in front of the cardholder.
Only PaymentType::MainRest and PaymentType::BindingMainRest are accepted by
the binding endpoints; anything else is rejected here, before dispatch, because
the gateway answers an invalid one with an unparseable HTTP 500.
Not implemented
Three gateway operations have no method on this client, and the omissions are deliberate rather than pending. Read this section as being about absence and the next one as being about doubt: what is listed here does not exist, so there is nothing to call; what is listed under Unverified behaviour is implemented and shipped, and it is only the confirmation that is missing.
GetTransactionList— SOAP-only.api-surface.json, the scraped REST manifest that is this package's specification of record, contains no endpoint returning a full transaction list over a date range, and v1.0 does not ship a second transport for a single operation. A merchant who needs it calls the SOAP service directly:docs/DEVELOPMENT-HISTORY.md, under What is not implemented, and why, records a working envelope, theSOAPActionheader that routes it and the XXE hardening it requires, andEnvironment::reportingSoapUrl()returns the host for the environment you constructed. That document isexport-ignored and does not ship in the installed package — read it in the repository.GetProblemTransactions— SOAP-only, andreports()->pending()(GetPendingTransactions, REST) is intended to cover this case. Two caveats, because that intent is weaker than it sounds. The manifest is scraped from the REST Help pages and holds no record of the SOAP surface at all, so it cannot establish the equivalence: that is inferred from the response shape —GetPendingTransactionsreturns rows carrying a per-rowErrorMessage, which is the shape of a problem list rather than a complete one — and is not read off anything. AndGetPendingTransactionshas itself never been called against the sandbox, so its shipped shape is the manifest's declaration alone.SSNCheck— unrelated to the payment lifecycle, and it carries Armenian national identity data:SSNandIdentifierType, which this package's security rules require be handled exactly as credentials are — never logged, never placed in an exception message, never in a URL. v1.0 does not take that obligation on for an operation no payment needs.
None of the three is scheduled. If you need one, say so in an issue rather than reading this section as a roadmap.
Unverified behaviour
Some of this package is built against a specification the sandbox has never
confirmed. Everything below is implemented — unlike the section above, what is
missing here is a confirmed observation, not a method. Those places are marked
@todo unverified in the source and catalogued in full under What the sandbox
never confirmed in docs/DEVELOPMENT-HISTORY.md,
which is export-ignored and does not ship in the installed package — read it in
the repository. The ones most likely to matter to a merchant:
- The bindings endpoints and
ConfirmPaymenthave never succeeded. They are not permitted on the sandbox client this package was built against, so everything in the previous section follows the API manifest and no observed response. - No real decline has ever been seen. Two payments have completed and both were approved. What the gateway sends when a card is refused — the code, the message, the shape of the body — is unobserved, and this package neither interprets nor tabulates response codes for exactly that reason.
- Only
Currency::AMDhas been accepted. USD, EUR and RUB are transcribed from the vendor PDF and have never been sent, let alone accepted. AMD is now confirmed in both directions — sent as"051"and echoed back as"051"— which widens the evidence for that one member and the set by nothing. - No fractional amount has ever reached the wire. Every amount in every run
was whole. The decimal-string encoding this package emits is no longer
unexercised —
"10.00","4.00"and"3.00"have each been sent by this package as JSON strings and accepted — but every one of those fractions was00, so what the gateway does with a fractional amount is exactly as unknown as before.Amountcarries integer minor units with an ISO 4217 exponent of 2, which is the standards-correct choice rather than an observed one. - Neither production host has ever been reached. Every observation behind this package came from the test environment.
paymentPageUrl()takes an optional third argument, aPaymentTypethat appends&type=to select the card form directly. Its only source is a third-party package's documentation — not the vendor PDF, not the API manifest — and no probe has ever sent it. Until recently no probe could: the sandbox payment page did not render. It does now, so this is a claim that is untested rather than untestable, and settling it needs one payment opened twice. It is left out of the examples above until somebody does that. Read What the sandbox never confirmed before depending on it.- How far the
TrxnDescriptionsubstitution reaches is unknown. The behaviour itself is observed and documented under The payment lifecycle: an ArmenianDescriptioncomes back with its letters replaced. What is unverified is its extent — Russian and every other non-Latin script are untested,Opaquehas only ever carried ASCII, and the cause is an open question with the bank. Treat any non-ASCII value you send as something to read back, never to compare.
API reference
The gateway surface this client targets is documented in
docs/api-reference/reference.md,
generated from the live vPOS Help pages. That reference — and the
api-surface.json manifest beside it — is the specification of record for this
package.
The reference is committed for versioning, but it is export-ignored and does
not ship in the distributed package. Read it in the repository.
Security
Vulnerabilities must be reported privately, never through a public issue. See
SECURITY.md.
The library redacts credentials, card data and personal data from its logs, and
never accepts a switch to disable TLS verification. Know one detail before you
read a log file: a card number is truncated, not removed. A value under a
card-number key survives as first-6 and last-4 with the middle masked whenever
it is shaped like a card number, and is replaced outright when it is not,
because a masked PAN is what reconciliation needs and the gateway already sends
that field pre-masked in exactly that shape. Every
other sensitive value is replaced outright — passwords, the approval code, the
expiry date, and the two fields that identify a person rather than a card:
ClientName holds the cardholder's own name, not the merchant's, and
ProcessingIP holds the address the cardholder paid from, not your server's
(CONVENTIONS.md §4.20).
Exceptions serialize with their sensitive state scrubbed
Every exception this package throws defines __serialize(), so one can be put in
a queue payload, a cache entry or a failed-job record without taking anything
with it that does not belong there. Serializing never fails, including on a
routine decline.
What survives a round trip:
getMessage(), and the file and line the failure was thrown from.- The stack trace reduced to its call path — file, line, function, class, type. The arguments are dropped: a frame inside the transport holds the request body, and the request body holds your password.
- Each exception's own fields:
operation()wherever it is declared,responseCode()andresponseMessage()on anApiException,paymentId()on anIndeterminateStateException,statusCode()andfaultMessage()on aGatewayFaultException,causedByJson()on aSerializationException.
A response code keeps the type it arrived with. The gateway answers int from
InitPayment and string everywhere else, so failure code 20 is 20 from one
endpoint and "20" from another; a restored exception reports whichever it was.
Compare with === and it will still be right.
What does not survive is the previous chain. The cause of a transport failure
is the PSR-18 client's own exception, and a PSR-18 exception hands back the
request it was sent — body, credentials and all — so it is dropped rather than
carried into a store this package cannot see. That is recorded rather than
hidden:
$restored = unserialize($stored); $restored->getPrevious(); // always null after a round trip $restored->chainDropped(); // true, false, or null
true— there was a cause and it was removed in transit.false— there was no cause to begin with.null— this object has not been through a round trip at all, sogetPrevious()is intact and authoritative.
chainDropped() is declared on VposExceptionInterface, so a worker that
catches the one interface can ask without narrowing first. A restored exception
is still throwable and still caught by its own class.
Contributing
All checks must pass before a pull request is merged:
| Command | Checks |
|---|---|
composer test |
PHPUnit |
composer stan |
PHPStan level 10 |
composer cs:check |
PHP-CS-Fixer, PER-CS 2.0 |
composer rector |
Rector, dry run |
composer validate --strict |
Composer manifest |
composer normalize --dry-run |
Manifest formatting |
composer coverage:check |
Line coverage 100% on src/ — PHP 8.3 / highest dependencies only |
composer infection |
Infection, MSI 100 on src/ — PHP 8.3 / highest dependencies only |
Verify against the supported floor, not your default interpreter. Composer
resolves script binaries through PATH, so prepend the PHP 8.3 bindir:
PATH="/path/to/php@8.3/bin:$PATH" composer test
Mutation testing requires a coverage driver. php-code-coverage 12 supports
only pcov and Xdebug — the phpdbg driver was removed upstream — and pcov is
considerably faster:
pecl install pcov
On Homebrew PHP for macOS that bare command fails to compile with
fatal error: 'pcre2.h' file not found, because Homebrew's
php-config --includes omits the pcre2 headers its own php_pcre.h
includes. Point the compiler at them:
CPPFLAGS="-I$(brew --prefix pcre2)/include" pecl install pcov
Verify with php -m | grep pcov. composer infection enforces a mutation score
of 100 on src/ — the floor is what the suite actually holds, because a floor
below the tree's real score is a silent regression budget. It runs in CI on the
PHP 8.3 / highest-dependencies job only.
composer coverage:check and composer infection therefore gate a pull request,
but on that single matrix job; the other five run the test suite with no coverage
driver. The two answer different questions and neither substitutes for the other:
Infection generates no mutants for uncovered code, so a mutation score alone
cannot see an untested class at all. composer bc is
not part of the gate at all — the backward-compatibility check needs a prior
release tag to diff against, and nothing has been tagged yet.
Bootstrap with composer install. composer cs applies the fixes that
composer cs:check reports.
CI splits the table across two workflows: the static gates — validate,
normalize, stan, cs:check and rector — run once on PHP 8.3, in an order
of their own; the table is a checklist, not a sequence. composer test runs
separately across the matrix PHP 8.3 / 8.4 / 8.5 against lowest and highest
resolvable dependencies, and composer infection runs on the 8.3 / highest cell
alone. normalize is a plugin command rather than a composer.json script, so
it is easy to miss after hand-editing composer.json — run it before opening a
pull request.
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.