Skip to content

Releases: tomsommer/onpay-php-sdk

4.0.0 - full API coverage, single request() method

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 19:04

Completes coverage of OnPay's documented API, and replaces the per-verb client methods with one.

Seven endpoints that had no implementation

GET /v1/transaction/events/ $api->transaction()->getEvents($cursor)
GET /v1/gateway/window/v3/language/ $api->gateway()->getPaymentWindowLanguages()
GET /v1/acquirer $api->acquirer()->getAcquirers()
GET /v1/acquirer/{name} $api->acquirer()->getAcquirer($name)
PATCH /v1/acquirer/{name} $api->acquirer()->updateAcquirer($name, $settings)
GET /v1/provider $api->acquirer()->getProviders()
GET /v1/wallet $api->acquirer()->getWallets()

Transaction events page by cursor rather than page number, which nothing else in this API does, so the collection carries the cursor instead of a Pagination:

do {
    $events = $api->transaction()->getEvents($cursor);
    foreach ($events->events as $event) { /* ... */ }
    $cursor = $events->nextCursor;
} while ($events->hasMore());

That implementation began as a branch Dennis Væversted pushed in July 2020 and nobody merged. It is preserved unmodified at archive/events-endpoint and brought up to date here.

Acquirer fields differ per acquirer — Nets carries card BINs, Clearhaus an API key — so only name, active and links are typed, and the rest is kept as returned and reached through getSetting(). Flattening them would give you a class whose properties are mostly null for any given acquirer.

Breaking: one request() instead of a method per verb

ApiClientInterface had get() and post(), which meant a verb OnPay uses and the SDK does not was simply unreachable — PATCH is what brought this to a head. Widening the interface for each new verb is not a contract worth keeping.

-public function get(string $url): mixed;
-public function post(string $url, mixed $body = null): mixed;
+public function request(string $method, string $url, mixed $body = null): mixed;

This only affects code calling $api->getApiClient() directly, or implementing ApiClientInterface for a test double. The service classes are unchanged from the outside.

The wire format is deliberately unchanged. A GET carries no body and every other verb carries one, even when that body is null. OnPay has accepted a literal null from the cancel and capture calls for years, and this was not the release to find out whether it still would if the body vanished entirely.

177 tests, up from 164.

3.4.0 - token lifecycle, 401 retry, cart floats

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 18:14

Works through the remaining findings from the audit of 3.0.0. Requires tomsommer/oauth2-onpay ^2.1.

Token handling

  • A stored token carrying expires_in but no issue time never expired. expires_in is relative to the moment of issue, which a stored blob no longer records, so league recomputed the expiry from the current time on every read — leaving the token perpetually an hour fresh however old it really was. It is now treated as spent, and renewed.
  • An unparsable issued_at is likewise treated as spent rather than silently dropped.
  • Tokens are renewed 30 seconds early, so one that is technically alive but will be dead by the time it reaches OnPay does not cost a wasted round trip.
  • expires of 0 means no expiry in league's own terms; asking hasExpired() in that state raised a RuntimeException.
  • A non-string access_token no longer reaches the header, where it rendered as Bearer Array.
  • A malformed token response raised league's InvalidArgumentException, outside the documented exception contract. It is a TokenException now.

One retry after a 401

OnPay is the authority on whether a token is good. A 401 with a refresh token available is now worth exactly one refresh and retry, rather than an immediate failure over clock drift or a token revoked underneath us. With nothing to renew with, behaviour is unchanged — and a second 401 is final.

Cart totals

round(19.99 * 100) is 1999.0, and the total check compared it !== against int 1999, producing:

Cart total does not match amount for payment, cart total was calculated to: 1999, amount provided is: 1999

Amounts are normalised on the way into CartItem, as they already were for shipping and handling.

Also

SimplePayment indexed the response envelope unguarded — the same fault already fixed in the service classes. Converter returned false for anything but the documented date format, putting a bool in properties typed \DateTime; it now returns null and understands ISO-8601. A numeric-string currency code is as valid as an int. And "0" is a legitimate identifier that empty() was calling absent.

PaymentService, GatewayService, PaymentInfo and Converter had no tests at all; they do now. 164 tests, up from 140.

3.3.0 - OAuth state verification

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 18:03

The OAuth callback can now verify state.

authorize() has always returned a URL carrying a random state, but nothing could check it on the way back: finishAuthorize() took only the code, the facade exposed no way to reach the state, and the README's callback example spent the code without looking at it.

Upstream did have a check — it compared the returned state against crypt() of a fixed provider id, which is deterministic and therefore not a check. Removing that vestige in 2.0.0 left the gap visible rather than disguised.

Without a state check, a callback cannot be told apart from one an attacker made the visitor follow, which is login CSRF / authorization-code injection.

What changed

$authUrl = $api->authorize();
$_SESSION['onpay_oauth_state'] = $api->getState();   // new
header('Location: ' . $authUrl);

// on the callback
$api->finishAuthorize(
    $_GET['code'],
    $_GET['state'] ?? null,
    $_SESSION['onpay_oauth_state'] ?? null
);

The two states are compared with hash_equals() before the code is spent, so a mismatch never reaches the token endpoint.

This is additive: finishAuthorize($code) with no state behaves as it did, for callers who already do the comparison themselves. Supplying one half of the pair and not the other is treated as a mistake rather than a pass.

Both README flows — the plain one and the PKCE one — now store the state before redirecting and hand it back.

Integrations authenticating with StaticToken never enter this flow and are unaffected.

3.2.0 - path identifier encoding, boolean window fields, response-shape guards

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 17:57

Three defects found by an external audit of 3.0.0. All were inherited from upstream, and all are reproduced by tests that fail if the fix is reverted.

Identifiers could change the action

Identifiers were interpolated into the request path unencoded everywhere except getTransaction(). A value containing ? turns the intended suffix into a query string:

refundTransaction("1234/capture?", 500)
  → POST /v1/transaction/1234/capture?/refund

OnPay reads that as a capture, and a capture accepts the body a refund sends. The same trick turned a subscription cancel into an authorize. Every identifier that becomes a path segment is now encoded.

If your identifiers come from OnPay itself this was not reachable, but the contract was wrong.

setTestMode(false) broke every live payment

http_build_query() renders false as 0 when the window signs itself, but the raw bool renders as an empty string in a form field. Signed and posted therefore disagreed:

signed:   ...&onpay_reference=r&onpay_testmode=0
rendered: ...&onpay_reference=r&onpay_testmode=

so OnPay rejected the window for a bad HMAC. Anyone calling setTestMode($isTest) with false — the natural way to write it — had every live payment rejected. Booleans are now normalised to the string the query builder would have produced, which leaves the signature unchanged and fixes the rendering.

Malformed responses raised TypeError instead of ApiException

A response that was valid JSON of the wrong shape — an empty object, a bare string, a missing data member, a non-string error message — reached a DTO constructor and surfaced as a TypeError. Callers catch ApiException, so a surprising response became a fatal rather than a handled failure. declare(strict_types=1), added in 2.1.0, made this sharper than it had been.

ApiClient now rejects a success body that is not an object and ignores a non-string error message, and the new ResponseParser turns the envelope into a typed failure in one place rather than eleven. Absent links are still tolerated, since they are genuinely optional.

3.1.0 - HUF/RON/TRY currencies, onpay_website required

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 17:50

Checked against OnPay's technical reference rather than against itself. It had drifted in two places.

Three missing currencies

HUF, RON and TRY are accepted by OnPay but were rejected here. The allow-list was clearly generated from that page once and never revisited — both are ordered by ISO 4217 numeric code, and the three slot exactly where the reference puts them (HUF 348 after DKK, RON 946 and TRY 949 after USD).

Taking payments in those currencies already worked, since setCurrency() does not validate. What failed:

new Currency('HUF');   // ApiException: Unsupported currency provided: HUF

along with Currency::getPaymentMethods() and isPaymentMethodAvailable() for those three, and any payment method declaring ALL_CURRENCY_CODES under-reporting its supported currencies by three.

The list is deliberately what OnPay accepts, not what ISO 4217 defines, so it will drift again. CurrenciesTest now asserts it against the documented set, which turns the next drift into a failing test rather than a support ticket.

onpay_website is required

OnPay documents it as required; isValid() did not ask for it. A window missing it passed here and was turned away at the redirect instead.

$window->isValid();   // was true without a website, now false

If you build windows without calling setWebsite(), isValid() now returns false. That is the intended correction — OnPay would have rejected those windows anyway — but it is a behaviour change rather than a pure addition, so it is worth checking before upgrading.

validatePayment() is unaffected: it does not consult requiredFields, so verifying an inbound callback with a bare window that has only the secret set works exactly as before. That is covered by a test.

3.0.0 - OnPayAPI split into facade, TokenManager and ApiClient

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 17:15

OnPayAPI is split into three collaborators.

composer require tomsommer/onpay-php-sdk:^3.0

Why

OnPayAPI had accumulated five unrelated jobs: parsing options, managing the token lifecycle, sending HTTP, handing out service objects, and recording the last exchange for debugging. The tell was get() and post() — marked @internal, but necessarily public, because the four service classes call them. An @internal tag on a public method is a comment pretending to be an access modifier: nothing stopped a consumer posting arbitrary bodies straight past the typed service layer.

The shape now

  • OnPay\Auth\TokenManager reads, refreshes and stores the access token. Every write to storage goes through it, so the refresh-token carry-over and the legacy 1.x token format have one home.
  • OnPay\Http\ApiClient sends authenticated requests and maps the answers onto ApiException / TokenException / ConnectionException. It holds no per-request state, so a single instance is safe to share for the life of an application.
  • OnPayAPI is now a facade: options in, services out. It drops from ~590 lines to 271.

OnPay\Http\ApiClientInterface is the seam the service classes depend on — two verbs plus the platform string, with the exception contract written into the interface, because that is what the services lean on and what any substitute has to honour.

Breaking changes

  • OnPayAPI::get() / post() removed. Use the service objects, or getApiClient() for an endpoint they do not cover yet.
  • OnPayAPI::getLastHttpRequest() / getLastHttpResponse() removed. They forced the client to carry per-request state for debugging alone; a PSR-3 logger or PSR-18 middleware does it better and works under concurrency.
  • OnPayAPI::setHttpClient() removed. Pass the client to the constructor.
  • The four service classes take ApiClientInterface instead of OnPayAPI. Reach them through $api->transaction(), $api->subscription(), $api->payment(), $api->gateway().
  • Requires tomsommer/oauth2-onpay:^2.0, whose namespace is now TomSommer\.

If your integration only calls $api->transaction() / $api->subscription() / $api->payment() / $api->gateway() and catches the SDK exceptions, nothing in your code changes.

Also

The suite grows from 64 tests to 83: transport and token behaviour are now covered directly instead of through the facade, and the service tests mock two methods rather than the whole object. The leftover .gitlab-ci.yml from upstream is gone — it pointed at an internal registry and ran a test suite that no longer exists.

2.2.0 - OAuth provider extracted to tomsommer/oauth2-onpay

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 16:51

The OAuth provider moves out into its own package.

composer require tomsommer/onpay-php-sdk:^2.2

Why

OnPayProvider was an implementation detail of this SDK's auth, which meant anyone integrating OnPay who wanted OAuth but not the whole API client had nothing to find. It is now tomsommer/oauth2-onpay, a standalone League provider client that can be used on its own.

Standing alone means it owns what the SDK used to do for it: the gateway_id path segment and its validation now live in the provider, so OnPayAPI passes gatewayId and the two hosts rather than a pre-built authorization URL.

Breaking changes

  • OnPay\OnPayProvider is gone. getProvider() now returns Tomsommer\OAuth2\Client\Provider\OnPay, which is a League\OAuth2\Client\Provider\AbstractProvider exactly as before.
  • An invalid gateway_id now reports gatewayId must be a non-empty alphanumeric value (it said gateway_id before). The validation rule itself is unchanged.

Nothing else moved. OnPay\OnPayAPI, OnPay\StaticToken, OnPay\API\*, the constructor signature and the pkce_method option all behave as they did in 2.1.0.

2.1.0 - timing-safe payment-window verification, strict_types

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 15:55

Security and correctness release on the payment-acceptance path, plus strict_types across the tree.

composer require tomsommer/onpay-php-sdk:^2.1

Payment window verification

PaymentWindow::validatePayment() had three problems, all inherited from upstream:

  • The HMAC was compared with ===, which is not timing-safe. It now uses hash_equals().
  • Signed fields were selected by substring, not prefix. The window writes its fields with an onpay_ prefix, but verification collected anything matching strpos($key, 'onpay_') !== false. An unrelated query parameter that merely contained the substring — tracking_onpay_campaign, for instance — was pulled into the comparison set and broke verification of a perfectly good payment. Selection is now by prefix. There is a regression test that fails against the old code.
  • An array-valued onpay_hmac_sha1 reached the comparison. ?onpay_hmac_sha1[]=x is trivially injectable and hash_equals() rejects a non-string with a TypeError, so non-strings are now turned away before the compare.

The window secret is also typed and required where it is used. Hashing with an empty key produced a signature that could never match, so getFormFields(), generateSecret() and validatePayment() now raise MissingDataException rather than failing quietly.

Refresh tokens survive a refresh

RFC 6749 §6 permits a server to omit refresh_token from a refresh response, in which case the previous one stays valid. The SDK saved the response verbatim, dropping the refresh token and making the next expiry unrecoverable. The old token is now carried over.

strict_types

declare(strict_types=1) in all 61 files. Amounts travel through this SDK as numeric strings and get compared against ints, which is precisely where silent coercion does damage.

Breaking changes

  • PaymentWindow::setSecret() is typed ?string; getSecret() returns ?string.
  • getFormFields(), generateSecret() and validatePayment() throw OnPay\API\Exception\MissingDataException when no window secret has been set.
  • A query parameter containing onpay_ other than as a prefix is no longer part of the signed set. If you were relying on the old substring behaviour, you were relying on a bug.

Everything from 2.0.1 applies unchanged: league/oauth2-client, PSR-18 transport, PSR-3 logging, PSR-7 last-exchange accessors, PHP 8.2+.

2.0.1 - league/oauth2-client, PSR-18, PSR-3, PHP 8.2

Choose a tag to compare

@tomsommer tomsommer released this 07 Sep 15:50

First release of this fork, and a substantial modernization of the OnPay PHP SDK.

Install with:

composer require tomsommer/onpay-php-sdk:^2.0

The package replaces onpayio/php-sdk, so it drops into a project that depends on the upstream SDK without a conflict.

Why this fork exists

Upstream carried a bundled copy of fkooman/oauth2-client — a PHP 5.4-era library whose own README recommends using something else — and then spent years working around it: a fake sessionless Session, an OAuth state built with crypt(), and a PKCE challenge generated but sent with an empty verifier. Every HTTP call was hard-wired to cURL, failures went to error_log(), and PRs to fix the latter two sat unreviewed for nine months (onpayio#98, onpayio#99).

What changed

  • OAuth 2.0 now runs on league/oauth2-client. The whole vendored OnPay\OAuth\Client\* tree is gone, along with InternalTokenStorage, Session and CurlHttpClientLogger. Authorization, code exchange and token refresh go through a maintained library via OnPayProvider, exposed as getProvider() if you want to drive the flow yourself.
  • HTTP goes through any PSR-18 client, with PSR-17 factories. Pass your own (Symfony HttpClient, Guzzle, Buzz) or let php-http/discovery find one. Also settable at runtime with setHttpClient().
  • Failed responses are reported to a PSR-3 logger. OnPayAPI implements LoggerAwareInterface; without a logger it still falls back to error_log(), so nothing is dropped silently.
  • PKCE is opt-in and actually works. Set the pkce_method option and carry the verifier across the redirect with getPkceCode() / setPkceCode().
  • Malformed JSON and transport errors raise typed exceptions (ApiException, ConnectionException, TokenException) instead of yielding null.
  • getLastHttpRequest() / getLastHttpResponse() return the PSR-7 messages, not a partial hand-rolled copy — so you get the real headers, status and reason phrase. The response body stream is rewound for you.
  • PHP 8.2+, native types on the core classes, and CI running PHPUnit on PHP 8.2/8.3/8.4 (plus a lowest-dependencies job) and PHPStan level 5.

Upgrading from onpayio/php-sdk 1.x

OnPay\OnPayAPI, OnPay\StaticToken, everything under OnPay\API\* and the constructor signature new OnPayAPI($tokenStorage, $options) are unchanged, so for most integrations only the Composer package name changes.

Breaking changes:

  • OnPay\OAuth\Client\*, OnPay\InternalTokenStorage, OnPay\Session and OnPay\CurlHttpClientLogger were removed. Nothing in the public API referenced them.
  • OnPay\API\Http\Request and OnPay\API\Http\Response were removed. The last-exchange accessors return PSR-7 messages instead; getUri() now returns a UriInterface, so cast it to string.
  • TokenStorageInterface declares getToken(): ?string and saveToken(string $token): void. Add the types to your own implementation.
  • Tokens are stored in league/oauth2-client format. Tokens written by 1.x are still read, so existing installations keep working without re-authorizing.
  • A PSR-18 client must be installable. composer require pulls in php-http/discovery, which finds any client you already have; install one (for example symfony/http-client with nyholm/psr7, or guzzlehttp/guzzle) if you have none.
  • The SDK no longer sets a cURL timeout of its own, because it no longer owns the transport. Configure the timeout on the HTTP client you inject.

Known issues

  • If OnPay's token endpoint ever omits refresh_token from a refresh response (permitted by RFC 6749 §6), the stored token loses its refresh token and the next expiry is unrecoverable. Not observed in practice; fix queued.
  • 2.0.0 was tagged the same day and is superseded by this release. Use ^2.0.

Upstream project: https://github.com/onpayio/php-sdk — the commits behind onpayio#98 and onpayio#99 remain in this fork's history if OnPay ever wants to pick them up.