Skip to content
adumont-payplug edited this page Sep 16, 2026 · 6 revisions

unified-plugin-core

Core foundations shared library for Payplug e-commerce plugins (e.g. PrestaShop).

This is a PHP library that provides shared building blocks — contracts, exceptions, models, and helper utilities — used across Payplug's e-commerce plugins. It is distributed via Composer for plugin development, but the shipped code is bundled directly into plugin ZIPs (no live vendor/ install on the merchant's server).

This page is the API reference — what each class is and what it does. For step-by-step, end-to-end integration guides (Hosted Fields, alias payments, webhooks, refunds), see How-to guides.

Requirements

  • Docker — the only local requirement; PHP and Composer run inside a dev container, no local PHP install needed.
  • The library targets PHP 7.1+ at runtime. composer.json's require.php (>=7.4) reflects the build-tooling floor for consuming plugins, not the runtime the shipped code executes on.

Getting started

make install

Builds the dev Docker image (PHP 7.4-cli + Composer) and runs composer install inside it.

Command Description
make install Install dependencies + git hooks
make test Run the unit PHPUnit suite
make test-integration Run the integration PHPUnit suite; requires VPN access plus UPC_IT_* credentials in a local .env (copy .env.example) — tests skip themselves when unset
make coverage Run tests with a Clover coverage report at build/logs/clover.xml (feeds SonarCloud)
make stan PHPStan static analysis (level 8)
make cs-lint Code style check (dry-run)
make cs-fix Code style auto-fix
make quality cs-lint + stan + test
make shell Interactive shell in the dev container
make verify-71 Proves the PHP 7.1 runtime floor actually holds — see Compatibility

Project structure

PSR-4 autoloaded under the PayplugUnifiedCore\ namespace, rooted at src/:

src/
├── Auth/               OAuth2/PKCE + client-credentials authentication
├── Contracts/          interfaces defining the boundary with each consuming CMS plugin
├── DataValues/         durable, direction-agnostic value types (PaymentOutcome, OperationData)
├── Dto/                caller-built input to a payment-creation call
├── Exceptions/         exception types
├── Output/             value objects produced internally by a UPC method
├── Services/           clients against Payplug APIs (e.g. the Unified API)
├── Traits/             shared payload-building behaviour composed into Dto/ classes
├── Validators/         validate a Dto before it's used by a Services/ class
└── Utilities/
    └── Helpers/        stateless helper functions

Tests live under tests/ (namespace PayplugUnifiedCore\Tests\), mirroring this same layout.

Models/ no longer exists as a category — it was split into DataValues/ and Output/ once distinguishing "durable value with its own lifecycle" from "caller-built input" from "internally-produced output" stopped being a distinction worth blurring. Dto/ and Validators/ were similarly split out of Models//Utilities/Helpers/ once more than one DTO/validator was expected.

Contracts

src/Contracts/ holds the 8 interfaces that define the boundary between this library and each consuming CMS plugin (first real consumer: UHF/Sylius) — designed around what a CMS needs to provide, not the not-yet-built Unified API's shape. Each ships with a docblock sketching a Sylius and a WooCommerce implementation; this library itself contains no concrete implementations.

  • ILogger — structured logging sink (debug/info/error), decoupled from any CMS's native logger.
  • IConfigurationRepository — OAuth2 client credentials and Hosted Fields public key material, sourced from each CMS's own settings storage.
  • IPaymentRepository — persists OperationData and tracks webhook processing state for idempotency.
  • IOrderStateMutator — applies a PaymentOutcome to the CMS-native order, identified by order ID (not a CMS-native object, since Sylius and WooCommerce orders share no common type).
  • ILock — per-operation mutex preventing a retried webhook from being processed concurrently with itself.
  • ITokenCache — caches the OAuth2 JWT this library uses against the Unified API.
  • IOAuthHttpClient — narrow HTTP contract for OAuth2 token exchange only (not a general-purpose Unified API HTTP client, which is separate).
  • IUnifiedApiHttpClient — narrow HTTP contract for the Unified API: get() for reading resources (payment/operation retrieval) and postJson() for creating them (hosted-fields payment creation). Kept distinct from IOAuthHttpClient since token exchange (POST + form-encoded) and Unified API calls (GET/POST + bearer token + JSON) are different enough shapes that sharing one contract would blur both.

PaymentRequestPayload also lives in src/Contracts/ but is a different kind of contract — not a CMS boundary a plugin implements, but the internal type UnifiedApiPaymentService::createPayment() depends on instead of a concrete DTO. Both HostedFieldDto and PaymentDto implement its single createPayloadBody(): array method, which is what lets one method accept either without a native PHP union type (unsupported at this repo's PHP 7.1 floor). A CMS plugin never implements it.

Exceptions

PayplugUnifiedCore\Exceptions\PayplugException is the base type for every exception this library throws — catch it instead of a generic \Exception to handle any error raised by this package. Thirteen domain-specific subtypes let callers catch more precisely:

  • RefundAmountException — thrown by UnifiedApiPaymentService::createRefund() on a zero/negative refund amount (see Services)
  • InvalidRefundRequestException — thrown by the same method on an empty orderId/description
  • PaymentNotFoundException — thrown on a 404 by getPayment()/createRefund()
  • InvalidPhoneNumberException
  • CardOperationException
  • ApiException
  • InvalidOperationDataException
  • InvalidTokenException
  • InvalidNotificationException — thrown by WebhookNotificationHelper (see Webhooks)
  • InvalidHostedFieldException — thrown by HostedFieldDtoValidator (see Validators)
  • InvalidPaymentException — thrown by PaymentDtoValidator (see Validators)
  • InvalidCommonFieldsException — thrown by CommonFieldsDtoValidator (see Validators)
  • OperationNotFoundException — thrown by UnifiedApiOperationService (see Services; that service is currently unverified in practice, see the caveat there)

Each behaves like a standard PHP exception: new SomeException($message, $code, $previous).

DataValues

PayplugUnifiedCore\DataValues\ holds durable, direction-agnostic value types — not the input to one specific call or the output of another, but data with a life of its own.

PayplugUnifiedCore\DataValues\PaymentOutcome expresses this library's payment result intent to the CMS, decoupled from any CMS's native order-status vocabulary — a set of class constants (a PHP 7.1 stand-in for a PHP 8.1 enum):

use PayplugUnifiedCore\DataValues\PaymentOutcome;

PaymentOutcome::PAID;             // 'paid'
PaymentOutcome::AUTHORIZED;       // 'authorized'
PaymentOutcome::CAPTURE_REQUIRED; // 'capture_required'
PaymentOutcome::THREE_DS_PENDING; // 'three_ds_pending'
PaymentOutcome::REFUNDED;         // 'refunded'
PaymentOutcome::FAILED;           // 'failed'

PaymentOutcome::isValid('paid');  // true
PaymentOutcome::isValid('bogus'); // false

PayplugUnifiedCore\DataValues\OperationData is the persistence value object built from a Payplug API response or webhook payload — its constructor is this library's validation boundary for that data, throwing InvalidOperationDataException on an empty operationId/execCode/orderId, a negative amount, or an outcome that isn't a PaymentOutcome constant:

use PayplugUnifiedCore\DataValues\OperationData;
use PayplugUnifiedCore\DataValues\PaymentOutcome;

$operation = new OperationData('op_123', '4001', PaymentOutcome::PAID, 4999, 'order_456');

$operation->operationId; // 'op_123'
$operation->execCode;    // '4001'
$operation->outcome;     // 'paid'
$operation->amount;      // 4999 (cents)
$operation->orderId;     // 'order_456'

It's produced both by WebhookNotificationHelper::parse() (see Webhooks) and by whatever IPaymentRepository implementation a CMS plugin persists/re-fetches it with — durable state with a life beyond any single call is what keeps it in DataValues/ rather than Output/.

Output

PayplugUnifiedCore\Output\ holds value objects produced entirely internally by a UPC method — the opposite direction from Dto/ below, which the caller builds.

PayplugUnifiedCore\Output\TokenOutput is the validating value object for an OAuth2 token response, constructed only from data that has already crossed the library's external boundary (an OAuth2 token-endpoint response) — its constructor throws InvalidTokenException on an empty accessToken/tokenType or a non-positive expiresIn:

use PayplugUnifiedCore\Output\TokenOutput;

$token = new TokenOutput('jwt-access-token', 3600, 'Bearer');

$token->accessToken; // 'jwt-access-token'
$token->expiresIn;   // 3600
$token->tokenType;   // 'Bearer'

PayplugUnifiedCore\Output\AuthorizationRequestOutput is the output of OAuth2Client::buildAuthorizationUrl() — the redirect URL plus the state/codeVerifier the caller must persist (session) to complete the flow on callback. Unlike TokenOutput, its constructor holds no validation, since it never crosses an external boundary itself:

use PayplugUnifiedCore\Output\AuthorizationRequestOutput;

$request = new AuthorizationRequestOutput($url, $state, $codeVerifier);

$request->url;          // redirect the merchant's browser here
$request->state;        // persist in session, compare on callback
$request->codeVerifier; // persist in session, needed for the token exchange

PayplugUnifiedCore\Output\PaymentOutput is the output of UnifiedApiPaymentService::createPayment() (see Services) — same unvalidated-constructor reasoning as AuthorizationRequestOutput, since it's produced entirely internally from a Unified API response already checked for a 2xx status:

use PayplugUnifiedCore\Output\PaymentOutput;

$output = $paymentService->createPayment($hostedFieldDto); // or $paymentDto

$output->status;       // int HTTP status
$output->body;         // raw JSON string from the Unified API
$output->redirectUrl;  // string|null — set only in "raw" 3DS-pending mode
$output->redirectHtml; // string|null — decoded HTML to inject when 3DS is pending (the common case)
$output->aliasId;      // string|null — the alias just created or reused, if any

Both redirectUrl and redirectHtml are null when the payment was processed synchronously (no 3DS challenge). redirectHtml is the "recommended for web" shape — a Base64-encoded redirect.html block, already decoded here into the raw HTML the CMS plugin must inject into its own page (it contains a form that auto-submits the end user to the bank's challenge page). redirectUrl only appears when the request set card.threeDSecure.displayMode=raw. Neither field maps to a PaymentOutcome — that happens later, via WebhookNotificationHelper (see Webhooks), once the asynchronous confirmation arrives.

aliasId carries the Unified API's paymentMethod.id back to the caller: the alias just created (a HostedFieldDto with paymentMethod.saveFutureUsage = true) or the one just reused (a PaymentDto payment). It's null when the payment involved no alias at all. Persist it to offer the customer a one-click payment later — see Dto.

This class was called HostedPaymentOutput until PRE-3590, when createHostedPayment() on the now-removed UnifiedApiHostedPaymentService became UnifiedApiPaymentService::createPayment(). Both the class and the method were renamed because a PaymentDto-based payment involves no hosted field at all.

Dto

PayplugUnifiedCore\Dto\ holds objects assembled by the CMS plugin itself as input to a payment-creation call. Like Output/, construction holds no validation of its own — that's a separate step, see Validators.

BrowserDto and CustomerDto hold end-user context reusable by any future payment-method DTO, not just hosted-fields. All fields are required constructor parameters — the Unified API schema requires them all-or-nothing whenever the parent object is sent at all, so a partial object simply can't be constructed:

use PayplugUnifiedCore\Dto\BrowserDto;
use PayplugUnifiedCore\Dto\CustomerDto;

$browser = new BrowserDto($ip, $referrer, $userAgent);
$customer = new CustomerDto($customerId, $email);

Sending browser is optional but strongly recommended whenever real end-user request data is available: it's what lets the card network/issuer attempt a frictionless (challenge-free) 3DS flow instead of always forcing one.

CommonFieldsDto holds the payment-creation fields common to every payment method:

use PayplugUnifiedCore\Dto\CommonFieldsDto;

$common = new CommonFieldsDto($accountId, $amountInCents, $currency, $orderId);

$common->description = 'Order #456';
$common->capture = true;              // false for an authorization-only hold
$common->descriptor = 'MYSHOP';       // shown on the customer's bank statement
$common->notificationUrl = 'https://merchant.example.com/webhook';
$common->extraData = 'order_456';     // echoed back verbatim in the webhook
$common->successUrl = 'https://merchant.example.com/checkout/success'; // 3DS/SCA return URL
$common->cancelUrl = 'https://merchant.example.com/checkout/cancel';   // 3DS/SCA cancel URL
$common->billing = $billingDto;       // optional, see "Billing and shipping" below
$common->shipping = $shippingDto;     // optional, see "Billing and shipping" below

submerchantExternalId is the optional fifth constructor parameter (new CommonFieldsDto($accountId, $amountInCents, $currency, $orderId, $submerchantExternalId)). It's a routing identifier belonging to the PayPlug UDV/MID configuration for the payment's currency, not to any payment method: the EUR configurations require one, the configurations used for other currencies have none at all. Pass it when your MID configuration owns one, omit it otherwise — sending a submerchant the configuration doesn't own is rejected by the API just as firmly as omitting one it does. An empty string is treated exactly like null (omitted from the request body), since a CMS reading an unset value out of its own settings storage yields '' far more often than a real null.

It was a required constructor parameter until PRE-3645. Existing calls that pass one keep working unchanged; multi-currency callers can now stop passing one.

description is the odd one out among the optional properties: the Unified API rejects a request missing that key entirely, so it's always sent, null included. Every other optional property above is omitted from the request body when unset.

Hosted-fields payment — HostedFieldDto

HostedFieldDto composes CommonFieldsDto/BrowserDto/CustomerDto plus the hosted-fields- specific pieces (hfToken, recurringMode, paymentMethod), and builds the exact Unified API request body via createPayloadBody():

use PayplugUnifiedCore\Dto\HostedFieldDto;

$dto = new HostedFieldDto($common, $hfToken, null, $browser, $customer, [
    'details' => ['fullName' => 'Jane Doe', 'selectedBrand' => 'visa'],
]);

$body = $dto->createPayloadBody(); // array — what UnifiedApiPaymentService POSTs

The full signature is __construct(CommonFieldsDto $common, string $hfToken, ?string $recurringMode = null, ?BrowserDto $browser = null, ?CustomerDto $customer = null, ?array $paymentMethod = null). recurringMode was inserted as the third parameter at PRE-3590 — an existing 5-argument call passing $browser third now passes it as recurringMode and will fail validation.

To also save the card as an alias for future payments, set paymentMethod.saveFutureUsage and pass a recurringMode:

$dto = new HostedFieldDto($common, $hfToken, 'ONE_CLICK', $browser, $customer, [
    'saveFutureUsage' => true,
    'details' => ['fullName' => 'Jane Doe'], // required whenever saveFutureUsage is true
]);

$output = $paymentService->createPayment($dto);
$aliasId = $output->aliasId; // persist this to offer one-click later

recurringMode is 'ONE_CLICK' or 'SUBSCRIPTION', and is only meaningful (and only sent) alongside saveFutureUsage = true. paymentMethod.details.fullName is otherwise optional, but the Unified API silently rejects an alias-creation request missing it — so HostedFieldDtoValidator enforces it, but only once saveFutureUsage is true (see Validators). Don't set paymentMethod['id'] here; that key belongs to PaymentDto's flow and is rejected by the validator.

Alias payment — PaymentDto

PaymentDto is HostedFieldDto's sibling for paying with an already-created alias — no hfToken, no card data at all. It composes the same CommonFieldsDto/BrowserDto/CustomerDto (a frictionless 3DS attempt on a saved alias still benefits from browser/customer context) and implements the same PaymentRequestPayload contract, so createPayment() accepts either:

use PayplugUnifiedCore\Dto\PaymentDto;

$dto = new PaymentDto($common, $aliasId, 'ONE_CLICK', $browser, $customer);

$output = $paymentService->createPayment($dto);

aliasId and recurringMode are both required here — an alias-based payment always declares which mode it's running under. The optional sixth $paymentMethod array carries supplementary card metadata (e.g. overriding the alias's saved brand); createPayloadBody() merges $aliasId into paymentMethod.id on your behalf, so don't set that key yourself. saveFutureUsage is rejected outright — creating an alias while paying with one isn't supported.

Billing and shipping

CommonFieldsDto's optional $billing/$shipping properties carry the payment's billing and shipping blocks. Unlike BrowserDto/CustomerDto, none of these fields are required together, so every constructor parameter defaults to null and each toArray() omits whichever are still unset:

use PayplugUnifiedCore\Dto\AddressDto;
use PayplugUnifiedCore\Dto\BillingDto;
use PayplugUnifiedCore\Dto\ContactDto;
use PayplugUnifiedCore\Dto\ShippingDto;
use PayplugUnifiedCore\Dto\ShippingScheduleDto;

$address = new AddressDto('1 rue de la Paix', 'Paris', 'FR', null, '75002');
$contact = new ContactDto('Jane', 'Doe', '+33123456789', '+33612345678');

$common->billing = new BillingDto($address, $contact, 'MRS');

$common->shipping = new ShippingDto(
    $address,
    $contact,
    'jane@example.com',
    'ACME Ltd',
    new ShippingScheduleDto($addressType, $timeFrame, $addressDate)
);
  • AddressDto (line, city, country, state, zipCode) is nested under an "address" key by both parents — it's the one sub-object the Unified API really does nest.
  • ContactDto (firstName, lastName, phone, mobilePhone) is the field group BillingDto and ShippingDto share. It is flattened into its parent, not nested: the API has no contact sub-object, these are flat sibling fields of billing/shipping on the wire.
  • ShippingScheduleDto (addressType, timeFrame, addressDate) groups the delivery-scheduling fields, also flattened into shipping.
  • BillingDto adds title ("MR", "MRS", or "MISS"); ShippingDto adds email and companyName.

ContactDto is distinct from CustomerDto despite the overlap in spirit: CustomerDto (id/email, both required together) identifies the payer for the risk/3DS context sent at the body's top level, while ContactDto is contact detail nested inside these two optional blocks. A new payment method needing "who is paying" should compose CustomerDto.

Validators

PayplugUnifiedCore\Validators\ validates a Dto/ object before a Services/ class uses it — validation is a deliberate separate step from construction, not folded into the DTO's constructor.

CommonFieldsDtoValidator::validate(CommonFieldsDto $dto): void checks accountId/orderId/ currency non-empty and amount not negative, throwing InvalidCommonFieldsException on the first problem found. Reusable by any future payment-method DTO that composes a CommonFieldsDto. Its validateOrWrap($dto, $exceptionClass) variant runs the same checks but re-throws as $exceptionClass, preserving the message and the original exception as $previous — that's how each payment-method validator below keeps callers down to a single exception type.

HostedFieldDtoValidator::validate(HostedFieldDto $dto): void delegates the common fields that way (wrapping into InvalidHostedFieldException), then checks the hosted-fields specifics:

use PayplugUnifiedCore\Exceptions\InvalidHostedFieldException;
use PayplugUnifiedCore\Validators\HostedFieldDtoValidator;

try {
    HostedFieldDtoValidator::validate($dto);
} catch (InvalidHostedFieldException $e) {
    // reject the request, log $e->getMessage()
}
  • hfToken must be non-empty — a hosted-fields payment can't exist without one.
  • paymentMethod must not set an id key; that identifier belongs to PaymentDto.
  • paymentMethod.details.fullName must be a non-empty string whenever paymentMethod.saveFutureUsage is true. The field is optional for a plain payment, but the Unified API silently rejects an alias-creation request missing it (confirmed against a real staging failure), so this is the one paymentMethod sub-field the validator enforces. saveFutureUsage is read through filter_var(..., FILTER_VALIDATE_BOOLEAN) rather than a strict === true, so a CMS handing back "1" or 1 from form data still triggers the check instead of silently skipping it.

PaymentDtoValidator::validate(PaymentDto $dto): void is the sibling for the alias-payment flow, wrapping the common fields into InvalidPaymentException and then checking:

  • aliasId and recurringMode both non-empty.
  • paymentMethod must not set an id key — createPayloadBody() merges aliasId in itself.
  • paymentMethod must not set a saveFutureUsage key at all; creating an alias while paying with one isn't supported. The key is rejected regardless of its value, not just when truthy.

The old browser/customer "all sub-fields present together" checks are gone from both — now that those are typed BrowserDto/CustomerDto objects rather than loose arrays, a partial one can't be constructed in the first place.

In practice you don't need to call either validator yourself — UnifiedApiPaymentService::createPayment() picks the right one for the DTO it was handed and runs it before any network call.

Traits

PayplugUnifiedCore\Traits\ holds behaviour composed into the Dto/ classes rather than duplicated across them. You never use these directly — they're private methods on whichever DTO uses them — but they're where a payment request body's exact shape is decided.

BuildsCommonPayloadBody builds the request body skeleton shared by every payment method: account/submerchantExternalId/amount/currency/orderId/description/capture, then the calling DTO's own payment-method-specific fields (hfToken; paymentMethod/recurringMode), then the optional browser/customer/descriptor/notificationUrl/extraData/billing/ shipping fields and a redirect object carrying whichever of successUrl/cancelUrl was set. Both HostedFieldDto::createPayloadBody() and PaymentDto::createPayloadBody() are thin wrappers over it. Two deliberate asymmetries live here: description is sent unconditionally, null included (the Unified API rejects a request missing the key, despite its own docs calling the field optional), while submerchantExternalId is omitted entirely when null or ''.

OmitsNullPropertiesFromArray is the shared toArray() body for the small value objects whose fields are plain public scalars keyed by their own property name — AddressDto, ContactDto, ShippingScheduleDto. It dumps the class's declared public properties in declaration order and drops the nulls. BillingDto/ShippingDto can't use it: they rename and nest keys (address) and flatten composed DTOs into themselves, so a straight property dump doesn't apply.

It's a trait rather than a shared abstract base class in both cases because the using classes are otherwise unrelated final classes with no other reason to share a type hierarchy.

Utilities

PayplugUnifiedCore\Utilities\Helpers\AmountHelper converts amounts between a major-unit float (e.g. a plugin's cart or order total) and the integer number of cents the Payplug API expects:

use PayplugUnifiedCore\Utilities\Helpers\AmountHelper;

AmountHelper::toCents(49.99);   // 4999
AmountHelper::fromCents(4999);  // 49.99

toCents() corrects the classic floating-point imprecision (19.99 * 100 evaluates to 1998.9999999999998 in raw PHP) by rounding before casting to int. For CMS platforms where the merchant can configure their own rounding algorithm (e.g. PrestaShop's PS_ROUND_MODE), pass the resolved mode explicitly — it only changes the result for amounts landing exactly on a half-cent boundary:

AmountHelper::toCents(19.995, PHP_ROUND_HALF_EVEN); // 2000
AmountHelper::toCents(19.995, PHP_ROUND_HALF_DOWN); // 1999

PayplugUnifiedCore\Utilities\Helpers\PhoneHelper normalizes a customer-entered phone number to E.164 (the format the Payplug API expects) and determines whether it's a mobile line, backed by giggsey/libphonenumber-for-php:

use PayplugUnifiedCore\Utilities\Helpers\PhoneHelper;

PhoneHelper::toE164('06 12 34 56 78', 'FR');  // "+33612345678"
PhoneHelper::isMobile('06 12 34 56 78', 'FR'); // true

$countryCode is a 2-letter ISO 3166-1 alpha-2 region code (the UK's is GB, not UK). Invalid or unparseable input throws InvalidPhoneNumberException from both methods.

PayplugUnifiedCore\Utilities\Helpers\PkceHelper generates the PKCE material for the authorization-code flow:

use PayplugUnifiedCore\Utilities\Helpers\PkceHelper;

$codeVerifier = PkceHelper::generateCodeVerifier();
$codeChallenge = PkceHelper::deriveCodeChallenge($codeVerifier); // S256 only
$state = PkceHelper::generateState();

PayplugUnifiedCore\Utilities\Helpers\Assert holds the shared "field must not be empty" / "must not be negative" / "must be positive" checks used internally by CommonFieldsDtoValidator, OperationData, and TokenOutput — each caller supplies its own exception class so the thrown type still matches its existing contract:

use PayplugUnifiedCore\Utilities\Helpers\Assert;

Assert::notEmpty($value, 'fieldName', SomeException::class);    // throws if $value === ''
Assert::notNegative($value, 'fieldName', SomeException::class); // throws if $value < 0
Assert::positive($value, 'fieldName', SomeException::class);    // throws if $value <= 0

// the same idea for a disallowed array key rather than a scalar field:
Assert::paymentMethodIdNotSet($paymentMethod, 'PaymentDto', SomeException::class);

paymentMethodIdNotSet() is the shared runtime guard behind the "paymentMethod must not set id directly" rule both HostedFieldDtoValidator and PaymentDtoValidator enforce; the third argument is the hint naming what to use instead.

Auth

PayplugUnifiedCore\Auth\OAuth2Client implements the OAuth2/PKCE and client-credentials flows against the identity provider. It has no caching of its own and never calls header() — the caller performs the actual redirect:

use PayplugUnifiedCore\Auth\OAuth2Client;

$client = new OAuth2Client($httpClient, 'https://api.payplug.com', 'https://merchant.example.com/callback', 'payments', 'https://www.payplug.com');

// Interactive merchant connection:
$authorizationRequest = $client->buildAuthorizationUrl($clientId); // AuthorizationRequestOutput
// redirect to $authorizationRequest->url; persist ->state and ->codeVerifier in session

// On the callback, after checking the returned state matches:
$token = $client->exchangeAuthorizationCode($clientId, $code, $codeVerifier); // TokenOutput

// Background API calls:
$token = $client->getClientCredentialsToken($clientId, $clientSecret); // TokenOutput

PayplugUnifiedCore\Auth\TokenManager wraps the client-credentials flow with caching, for background API calls that shouldn't hit the identity provider on every request:

use PayplugUnifiedCore\Auth\TokenManager;

$tokenManager = new TokenManager($tokenCache, $client);

$accessToken = $tokenManager->getValidToken($clientId, $clientSecret); // string JWT, ready for an Authorization header

refreshToken() is the escape hatch for a caller holding a token the API just rejected — it drops the cached entry and mints a replacement, so a token invalidated before its cache TTL expires (rotated secret, revoked grant, clock skew) doesn't keep failing every call:

$accessToken = $tokenManager->refreshToken($clientId, $clientSecret); // bypasses the cache

Services

PayplugUnifiedCore\Services\AbstractUnifiedApiService holds the mechanics shared by every concrete service below: resolving a client-credentials JWT via TokenManager, retrying a request exactly once on a 401 with a freshly minted token, and normalizing IUnifiedApiHttpClient's response shape. Every service takes the same five constructor arguments (IUnifiedApiHttpClient $httpClient, TokenManager $tokenManager, string $baseUrl, string $clientId, string $clientSecret) and shares its error-handling conventions — you don't use this class directly, but its behavior applies to both services below.

PayplugUnifiedCore\Services\UnifiedApiPaymentService is where every payment concern lives: reading a payment or operation, creating a payment, and refunding one.

use PayplugUnifiedCore\Services\UnifiedApiPaymentService;

$service = new UnifiedApiPaymentService($httpClient, $tokenManager, 'https://api.payplug.com', $clientId, $clientSecret);

UnifiedApiHostedPaymentService no longer exists. It was removed at PRE-3590 and its createHostedPayment() became createPayment() on this service — a PaymentDto-based payment involves no hosted field at all, which made both the separate service and the old method name misleading for that flow.

Reading a payment or operation

getPayment() and getOperation() return the raw HTTP response — a parsed payment data model is separate, future scope:

$response = $service->getPayment('5298ff38-883a-465f-b759-aec78cee203e');

$response['status']; // 200
$response['body'];   // raw JSON string from the Unified API

$response = $service->getOperation($operationId); // the public operation endpoint

getOperation() hits the public operation endpoint (/processing-operations/operations/public/{id}). Its response is a flat, webhook-shaped payload (id/execCode/orderId/amount) — the same shape WebhookNotificationHelper::parse() turns into an OperationData (see Webhooks) — which makes it useful as a polling fallback for a delayed or lost webhook, e.g. to resolve a stuck PaymentOutcome::THREE_DS_PENDING.

Creating a payment

createPayment(PaymentRequestPayload $dto): PaymentOutput POSTs to /api/payment-gateway/payments. It accepts either DTO that implements the contract — a HostedFieldDto (hfToken-driven, optionally also creating an alias) or a PaymentDto (paying with an existing alias, no card data at all):

$output = $service->createPayment($hostedFieldDto); // or $paymentDto — PaymentOutput, see Output above

$output->redirectHtml; // inject into your page when 3DS is pending
$output->aliasId;      // persist when you asked for an alias

Both DTOs hit the same endpoint with the same request/response shape, so one method covers both. It validates first, before any network call — HostedFieldDtoValidator or PaymentDtoValidator depending on the DTO handed in (see Validators), throwing InvalidHostedFieldException or InvalidPaymentException respectively. A PaymentRequestPayload implementation the method doesn't recognize throws \LogicException: every implementation must be validated before use, so an unwired one is a programming error rather than a recoverable condition.

The request body is built entirely by $dto->createPayloadBody() — every field it needs already lives on the DTOs, so the service constructs nothing itself. That includes accountId, which lives on CommonFieldsDto rather than the service's constructor: it's data about this specific payment request, not shared connection configuration, and has no relationship to the OAuth2 clientId/clientSecret pair.

Refunding a payment

createRefund() creates a full or partial refund. Omit $amount to refund the payment's full remaining amount:

$response = $service->createRefund(
    $operationId,              // the payment's own id
    $accountId,
    $orderId,                  // required
    $description,              // required
    $submerchantExternalId,    // optional — pass null when the MID configuration owns none
    $amountInCents,            // optional — null refunds everything remaining
    $currency                  // optional
);

$response['status']; // 200
$response['body'];   // raw JSON string from the Unified API

The refund is keyed by the payment's own id — the same value this library's OperationData/webhook vocabulary already calls operationId, which is why that's the parameter name here rather than a second name for the same thing.

orderId and description are both genuinely required, and are checked locally before any HTTP call — ApiException carries only the HTTP status, not the API's own response body naming which field was missing, so a local check gives a far more useful InvalidRefundRequestException message. Both were confirmed required by probing each field's absence individually against the real staging API (2026-08-27), not merely by reading the GitBook doc.

submerchantExternalId and currency are optional, and each is sent only when non-null and non-empty:

  • submerchantExternalId must mirror the payment being refunded. It belongs to the MID configuration for the payment's currency: refunding a EUR payment (whose configuration owns a submerchant) without it fails with 400 The parameter "subMerchantExternalId" is missing., while refunding a non-EUR payment with it fails with 400 Invalid parameter. and succeeds when it's omitted (staging, 2026-09-04). When sent, the API validates the lower-case submerchantExternalId key despite its own error text capitalizing it.
  • currency only became available on this endpoint on 2026-09-04. Before that an $amount travelled bare and the platform inferred what those minor units meant — harmless while every payment was EUR, ambiguous for a multi-currency merchant. Passing null or '' falls back to that same inference mode rather than putting "" on the wire for the API to reject.

Caveat on the evidence: the 2026-09-04 staging run that first succeeded changed both submerchantExternalId and currency at once, so which of the two the earlier Invalid parameter. referred to was never isolated.

A $amount that is zero or negative throws RefundAmountException. An amount exceeding what was captured is not checked here — the Unified API rejects that itself, so the check isn't duplicated.

UnifiedApiOperationService

PayplugUnifiedCore\Services\UnifiedApiOperationService fetches a single operation from the internal operation endpoint (/processing-operations/operations/{id}), throwing OperationNotFoundException on a 404:

use PayplugUnifiedCore\Services\UnifiedApiOperationService;

$service = new UnifiedApiOperationService($httpClient, $tokenManager, 'https://api.payplug.com', $clientId, $clientSecret);

$response = $service->getOperation($operationId);

Caveat: this private-endpoint path has been observed returning HTTP 403 for a merchant's own client credentials in staging — i.e. it does not currently work for the only credential type this library uses. UnifiedApiPaymentService::getOperation() above, against the public endpoint, is the path actually confirmed working end-to-end. Treat UnifiedApiOperationService as unverified/likely non-functional until a follow-up resolves this.

Error handling

Shared across both services:

  • 404 on getPayment() and createRefund() throws PaymentNotFoundException; on UnifiedApiOperationService::getOperation() it throws OperationNotFoundException. Both are siblings of ApiException, not subclasses, so catching ApiException alone will not catch them. UnifiedApiPaymentService::getOperation()'s 404 is not special-cased — it throws the generic ApiException, since a caller polling it as a webhook fallback treats every failure the same way.
  • 401 is retried once with a freshly minted JWT (the cached one is discarded first); only a second 401 throws.
  • Any other non-2xx status, or a malformed IUnifiedApiHttpClient response, throws ApiException.
  • Validation failures (InvalidHostedFieldException, InvalidPaymentException, InvalidRefundRequestException, RefundAmountException) are all raised before any network call, so catching them tells you nothing was sent.

Every exception type here carries the HTTP status as its exception code, so you can branch without parsing the message. The code is 0 only when the response shape was unusable and no status was received:

try {
    $response = $service->getPayment($paymentId);
} catch (PaymentNotFoundException $e) {
    // $e->getCode() === 404
} catch (ApiException $e) {
    // $e->getCode() === 503, 500, … or 0 if the HTTP client returned an unusable shape
}

Webhooks

PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper parses and validates an asynchronous "Payment Operation" notification (webhook/3DS confirmation), independently of the CMS that receives the HTTP request:

use PayplugUnifiedCore\DataValues\PaymentOutcome;
use PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper;

$expectedHeader = $configurationRepository->get('payplug_webhook_authorization_header');
$operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader);

if ($operationData->outcome !== PaymentOutcome::THREE_DS_PENDING) {
    $paymentRepository->save($operationData);
    $orderStateMutator->apply($operationData->orderId, $operationData->outcome);
}

verifySignature() does a constant-time comparison of the notification's Authorization header against $expectedAuthorizationHeader, throwing InvalidNotificationException when it's absent or doesn't match — the platform has no HMAC/signature-over-body scheme, only a shared secret configured at webhook-creation time. When $expectedAuthorizationHeader is empty, verification is skipped and the notification is accepted unverified rather than rejected — a deliberate, known temporary trade-off, since no merchant/account currently has a way to configure a webhook secret at all. Revisit once that product decision lands; don't treat it as settled behavior.

parse() can return an OperationData with outcome === PaymentOutcome::THREE_DS_PENDING: PayPlug's notifier has been observed firing a notification carrying execCode "0001" (3DS authentication required) before the real, final notification for the same operation. Treat a THREE_DS_PENDING result as "no new information yet" — do not call IPaymentRepository::markTreated() for it, or the later, final notification will be permanently blocked from ever applying. A CMS controller resolves a previously-pending OperationData to its final state simply by calling parse() again on a later webhook.

PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper::toPaymentOutcome(string $execCode): string is what WebhookNotificationHelper::parse() (and the synchronous hosted-payment creation flow) use to translate a Payplug execCode into the PaymentOutcome vocabulary above — "0000"PAID, "0001"THREE_DS_PENDING, everything else → FAILED:

use PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper;

ExecCodeMapper::toPaymentOutcome('0000'); // PaymentOutcome::PAID
ExecCodeMapper::toPaymentOutcome('0001'); // PaymentOutcome::THREE_DS_PENDING
ExecCodeMapper::toPaymentOutcome('5NN3'); // PaymentOutcome::FAILED

Compatibility

Code under src/ and tests/ must not use PHP syntax newer than 7.1 (no typed properties, arrow functions, constructor property promotion, match, enum, etc.), since the shipped code must run on older PHP hosts. This is enforced two ways: a CI job lints every file directly with php -l across PHP 7.1–8.2, and make verify-71 goes further by booting a real --no-dev vendor tree under an actual PHP 7.1 interpreter and smoke-testing PhoneHelper/AmountHelper end to end — run it after touching any dependency version. See Architecture for details, including why composer.json's own platform-check is disabled.

License

MIT

Clone this wiki locally