Skip to content
adumont-payplug edited this page Oct 8, 2026 · 8 revisions

unified-plugin-core

Shared PHP library behind Payplug's e-commerce plugins (PrestaShop, WooCommerce, Sylius). It owns the Payplug protocol — OAuth2, the Unified API, webhooks — so each plugin only has to supply its CMS-specific parts. Distributed via Composer, but the shipped code is bundled into plugin ZIPs (no live vendor/ on the merchant's server).

Where to start

I want to… Go to
Understand how the pieces fit Architecture
Integrate a payment flow step by step How-to guides
Look up a class or method the reference below
Contribute Contributing · Release process

Quick start

Docker is the only local requirement; PHP and Composer run in a dev container.

make install
Command Description
make install Install dependencies + git hooks
make test Unit tests
make test-integration Integration tests; needs VPN and UPC_IT_* credentials in a local .env (copy .env.example). Tests skip themselves when unset
make coverage Tests + Clover report at build/logs/clover.xml (feeds SonarCloud)
make stan PHPStan level 8
make cs-lint / make cs-fix Code style check / auto-fix
make quality cs-lint + stan + test
make shell Shell in the dev container
make verify-71 Proves the PHP 7.1 runtime floor holds — see Compatibility

The runtime target is PHP 7.1+. composer.json's require.php (>=7.4) is the build-tooling floor, not the runtime.

The 30-second picture

// 1. Wire: your plugin implements the contracts, then builds the services once.
$service = new UnifiedApiPaymentService($httpClient, $tokenManager, $baseUrl, $clientId, $clientSecret);

// 2. Pay: build DTOs, call the service, show the 3DS page if one is pending.
$output = $service->createPayment(new HostedFieldDto($common, $hfToken, null, $browser, $customer));

// 3. Confirm: the final result arrives at your webhook.
$operation = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader);

Everything is stateless: UPC never stores a credential, writes an order or redirects the browser. Those are your plugin's job, through the contracts.

Reference

Namespace PayplugUnifiedCore\, rooted at src/. Tests mirror the layout under tests/.

Contracts

Contracts/ holds the 8 interfaces your plugin implements. UPC ships no concrete implementation; each interface carries a docblock sketching a Sylius and a WooCommerce one.

Interface Provides
ILogger Structured logging sink (debug/info/error)
IConfigurationRepository OAuth2 client credentials and Hosted Fields key material, from the CMS's settings
IPaymentRepository Persists OperationData; tracks webhook processing state for idempotency
IOrderStateMutator Applies a PaymentOutcome to the CMS order, identified by order id
ILock Per-operation mutex so a retried webhook isn't processed concurrently with itself
ITokenCache Caches the OAuth2 JWT
IOAuthHttpClient HTTP for OAuth2 token exchange only (POST, form-encoded)
IUnifiedApiHttpClient HTTP for the Unified API: get() and postJson() (bearer token, JSON)

PaymentRequestPayload also lives here but is internal: the type createPayment() accepts, which HostedFieldDto and PaymentDto implement through createPayloadBody(): array. You never implement it.

DataValues and Output

Class Kind What it is
PaymentOutcome constants PAID · AUTHORIZED · CAPTURE_REQUIRED · THREE_DS_PENDING · REFUNDED · FAILED; isValid()
AuthorizationType constants PRE_AUTHORIZATION · FINAL_AUTHORIZATION; isValid() is case-insensitive
OperationData validating operationId, execCode, outcome, amount, orderId. Throws InvalidOperationDataException on empty ids/code, negative amount or an unknown outcome. Built by WebhookNotificationHelper::parse() and persisted by IPaymentRepository
TokenOutput validating accessToken, expiresIn, tokenType, optional idToken. Throws InvalidTokenException. idToken is the OIDC ID token of an openid-scoped authorization-code exchange (PRE-3631); null for client credentials
AuthorizationRequestOutput plain url, state, codeVerifier from buildAuthorizationUrl()
PaymentOutput plain status, body, redirectHtml (3DS page to inject), redirectUrl (raw mode only), aliasId, and for authorizations maxCaptureDate, remainingCapturableAmount
CaptureOutput plain status, body, capturedAmount, requestedAmount, maxCaptureDate, derived remainingCapturableAmount
CancellationOutput plain status, body, cancelledAmount, requestedAmount, derived remainingCancellableAmount

"Validating" objects reject bad data in their constructor and must be built only from data that has already crossed the library boundary. "Plain" ones are produced internally and hold no validation. The derived remaining… amounts are hints, not authoritative — see Capturing or cancelling.

Dto

Dto/ holds what your plugin builds. Construction does no validation; that is a separate step (Validators).

DTO Purpose
CommonFieldsDto Fields common to every payment method
HostedFieldDto Hosted-fields payment: hfToken, optional recurringMode, paymentMethod
PaymentDto Payment with an existing alias: aliasId, recurringMode
BrowserDto ip, referrer, userAgent. All required together; improves the odds of a frictionless 3DS
CustomerDto id, email. Required together
BillingDto / ShippingDto Optional blocks on CommonFieldsDto; every field optional
AddressDto · ContactDto · ShippingScheduleDto Building blocks of the two above
$common = new CommonFieldsDto($accountId, $amountInCents, 'EUR', $orderId);
$common->description = 'Order #456';
$common->notificationUrl = 'https://merchant.example.com/webhook';
$common->successUrl = '…';   // 3DS return URLs
$common->cancelUrl = '…';

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

CommonFieldsDto rules worth remembering:

  • accountId, amount (integer cents), currency and orderId are required. Convert with AmountHelper with the order's currency, never * 100.
  • description is always sent, null included (the API rejects a missing key). Every other optional property is omitted when unset.
  • capture = false makes an authorization-only hold. partialAuthorization and authorizationType are only valid with it; the validator rejects them otherwise.
  • BrowserDto::toArray() sends 0.0.0.0 in place of any IPv6 address, because the API caps browser.ip at IPv4 length (PRE-3713). The $ip property is unchanged.

recurringMode is 'ONE_CLICK' or 'SUBSCRIPTION'. Breaking note: it became the third HostedFieldDto constructor parameter at PRE-3590, so an old 5-argument call passing $browser third now passes it as recurringMode and fails validation.

Saving a card as an alias: set paymentMethod['saveFutureUsage'] = true with a recurringMode and paymentMethod['details']['fullName'] (the API silently rejects an alias creation without it), then persist PaymentOutput::$aliasId (read from paymentMethod.storedId, falling back to paymentMethod.id). For a payment made with an existing alias the current API no longer echoes it back, so aliasId is null there: keep using the alias you sent. Billing/shipping ContactDto fields are flattened into their parent, AddressDto is nested under address. Full flows: How to implement Hosted Fields and alias payments.

Validators

createPayment() picks and runs the right validator before any network call, so you rarely call them yourself. Each wraps CommonFieldsDtoValidator and re-throws as its own type, so a caller catches one exception per DTO.

Validator Throws Checks beyond the common fields
CommonFieldsDtoValidator InvalidCommonFieldsException accountId/orderId/currency non-empty, amount not negative, valid authorizationType, no authorizationType/partialAuthorization when capture is true
HostedFieldDtoValidator InvalidHostedFieldException hfToken non-empty; no paymentMethod.id, storedId or hfToken (the token is merged in for you); details.fullName non-empty when saveFutureUsage is true (read with FILTER_VALIDATE_BOOLEAN, so "1" counts)
PaymentDtoValidator InvalidPaymentException aliasId and recurringMode non-empty; no paymentMethod.id or storedId (both merged in for you); no saveFutureUsage key at all

Traits

Private to the DTOs, listed because they decide a request body's shape. BuildsCommonPayloadBody builds the skeleton shared by every payment method (account, amount, currency, order, description, capture, the payment-method fields, optional blocks, redirect, and partialAuthorization / operation.authorizationType). OmitsNullPropertiesFromArray is the shared toArray() for AddressDto, ContactDto and ShippingScheduleDto.

Utilities

Stateless static helpers in Utilities/Helpers/.

Helper Purpose
AmountHelper toCents(float $amount, string $currency, int $roundMode) / fromCents(int $cents, string $currency). The currency (ISO 4217 alpha-3) is required: zero-decimal currencies (JPY, XOF, …) convert with a factor of 1, all others with 100. Empty or malformed codes and 3-decimal currencies (BHD, KWD, TND, …) throw InvalidCurrencyException. Rounds before casting, since 19.99 * 100 is 1998.9999999999998. Pass the CMS's rounding mode (e.g. PrestaShop's PS_ROUND_MODE) to resolve half-minor-unit cases
PhoneHelper toE164($number, $country) and isMobile(...), backed by giggsey/libphonenumber-for-php. Country is ISO 3166-1 alpha-2 (GB, not UK). Throws InvalidPhoneNumberException
PkceHelper generateCodeVerifier(), deriveCodeChallenge() (S256 only), generateState()
Assert notEmpty / notNegative / positive / paymentMethodIdNotSet; the caller supplies the exception class
ExecCodeMapper toPaymentOutcome($execCode): "0000" → PAID, "0001" → THREE_DS_PENDING, anything else → FAILED. Shared by payment creation and webhooks
WebhookNotificationHelper verifySignature() and parse(), see Webhooks

Auth

$client = new OAuth2Client($httpClient, $baseUrl, $callbackUrl, 'payments', $identityUrl);

// Interactive merchant connection (never redirects for you)
$request = $client->buildAuthorizationUrl($clientId);                 // AuthorizationRequestOutput
$token   = $client->exchangeAuthorizationCode($clientId, $code, $codeVerifier); // TokenOutput

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

$tokenManager = new TokenManager($tokenCache, $client);
$jwt = $tokenManager->getValidToken($clientId, $clientSecret);  // cached, ready for a header
$jwt = $tokenManager->refreshToken($clientId, $clientSecret);   // after the API rejected a token

OAuth2Client has no cache of its own. TokenManager caches through ITokenCache and refreshToken() drops the cached entry before minting a replacement.

Services

Every service takes the same five arguments: IUnifiedApiHttpClient $httpClient, TokenManager $tokenManager, string $baseUrl, string $clientId, string $clientSecret. AbstractUnifiedApiService supplies the JWT, the one-time 401 retry and response normalization.

UnifiedApiPaymentService is where every payment concern lives:

Method Does Returns
getPayment($id) Fetch a payment. Currently 404s on staging since the API contract change (surfaces as PaymentNotFoundException); until the API team confirms, poll getOperation() with operationIds[0] from the creation response ['status', 'body']
getOperation($id) Fetch an operation from the public endpoint. Webhook-shaped, so useful as a polling fallback for a lost webhook ['status', 'body']
createPayment($dto) Create a payment or authorization from a HostedFieldDto or PaymentDto PaymentOutput
createRefund(...) Full or partial refund ['status', 'body']
capturePayment(...) Capture an authorization, in full or part CaptureOutput
cancelPayment(...) Void an authorization, in full or part CancellationOutput

createPayment() validates, builds the body from $dto->createPayloadBody() and POSTs it. An unrecognized PaymentRequestPayload throws \LogicException rather than skipping validation. accountId lives on CommonFieldsDto, not the service, because it describes one payment, not the connection.

Refunds

$service->createRefund($operationId, $accountId, $orderId, $description,
    null, $amountInCents, $currency);

$operationId is the payment's id. orderId and description are required and checked locally (InvalidRefundRequestException); a non-positive amount throws RefundAmountException; omitting $amountInCents refunds everything remaining; an amount above what was captured is left for the API to reject. currency has been available on this endpoint since 2026-09-04. Evidence and checklist: How to implement refunds.

Capturing or cancelling an authorization

An authorization is createPayment() with CommonFieldsDto::$capture = false. You then settle or release it:

$capture = $service->capturePayment($paymentId, $accountId, $orderId, $description, $amount, $extraData, $currency);
$cancel  = $service->cancelPayment($paymentId, $accountId, $orderId, $description, $amount, $extraData, $currency);

A null amount captures or cancels everything remaining. Several partial captures are possible only where the account/processor allows it. Partial cancellation needs to be enabled on the contract. $currency is required whenever a capture has an $amount. Full flow: How to implement captures and cancellations.

Do not treat the derived remaining… amounts as authoritative. The cancellation figure ignores earlier captures, and the capture figure rests on two assumptions never confirmed against a real success response. Keep your own running balance.

The response is classified in this order: 404 → PaymentNotFoundException; 409 → OperationConflictException; message contains "duplicate" → MultipleCaptureNotAllowedException (capture) or OperationConflictException (cancel); execCode starting with 4 → CardOperationException (issuer refusal); then message keywords ("expired", "not voidable", "not capturable", "already captured", "already cancelled/voided", "exceed") → the matching exception; partial cancel with errorCategory = INVALID_REQUEST → PartialCancellationNotAllowedException; anything else → ApiException. UPC cannot make these calls idempotent (it holds no state), but the distinct types let your own idempotency layer no-op a replay.

Known gap: a 2xx with non-terminal execCode 0002/0003 falls through to ApiException. Retrying after it risks a double operation once the pending one resolves.

UnifiedApiOperationService

Fetches an operation from the internal endpoint (/processing-operations/operations/{id}), throwing OperationNotFoundException on a 404. Treat it as unverified: that path returns HTTP 403 for a merchant's own client credentials in staging. UnifiedApiPaymentService::getOperation() is the path confirmed working.

Error handling

Every exception extends PayplugException and carries the HTTP status as its code (0 only when the response shape was unusable). Catch PayplugException for anything UPC raises.

Validation — raised before any network call, so catching one means nothing was sent:

Exception Raised by
InvalidCommonFieldsException / InvalidHostedFieldException / InvalidPaymentException the three DTO validators
InvalidRefundRequestException / RefundAmountException createRefund(): empty orderId/description; non-positive amount
InvalidCaptureRequestException / CaptureAmountException capturePayment(): same, plus an amount without a currency
InvalidCancellationRequestException / CancellationAmountException cancelPayment(): empty orderId/description; non-positive amount
InvalidOperationDataException / InvalidTokenException / InvalidPhoneNumberException / InvalidNotificationException OperationData, TokenOutput, PhoneHelper, WebhookNotificationHelper
InvalidCurrencyException AmountHelper: empty or non-ISO currency code, or a 3-decimal currency

API outcomes:

Exception Meaning
PaymentNotFoundException 404 on getPayment / createRefund / capturePayment / cancelPayment
OperationNotFoundException 404 on UnifiedApiOperationService::getOperation()
CardOperationException Issuer refused
OperationConflictException 409, or a duplicate on cancel: a concurrent or repeated request
MultipleCaptureNotAllowedException A later capture on an authorization that allows only one
AuthorizationExpiredException Captured after maxCaptureDate
PaymentAlreadyCapturedException / PaymentAlreadyCancelledException Operation already applied
PaymentNotCapturableException / PaymentNotVoidableException Payment's state doesn't allow it
AmountExceedsAvailableException Amount above what remains
PartialCancellationNotAllowedException Partial cancel on a contract without it
ApiException Any other non-2xx, or a malformed HTTP-client response

PaymentNotFoundException and OperationNotFoundException are siblings of ApiException, not subclasses, so catching ApiException alone won't catch them. A 401 is retried once with a fresh JWT; only a second 401 throws.

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

Webhooks

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

if ($operation->outcome !== PaymentOutcome::THREE_DS_PENDING) {
    $paymentRepository->save($operation);
    $orderStateMutator->apply($operation->orderId, $operation->outcome);
}
  • Verification. verifySignature() does a constant-time comparison of the Authorization header against the expected value and throws InvalidNotificationException on a mismatch. The platform has no HMAC over the body, only a shared secret. When the expected value is empty, verification is skipped and the notification accepted: a deliberate, known-temporary trade-off, since no merchant can currently configure a webhook secret. Revisit once that lands.
  • THREE_DS_PENDING is "no new information". Payplug has been seen sending an execCode "0001" notification before the final one for the same operation. Do not call IPaymentRepository::markTreated() for it, or the final notification is blocked for good. A later webhook resolves it when you call parse() again.

Full handling guide, including idempotency: How to handle webhooks.

Compatibility

src/ and tests/ must not use PHP syntax newer than 7.1 (no typed properties, arrow functions, constructor promotion, match, enum), because the shipped code runs on older hosts. A CI job lints every file with php -l across PHP 7.1–8.2, and make verify-71 boots a real --no-dev vendor tree under a PHP 7.1 interpreter. Run it after touching any dependency version. Details, including why platform-check is disabled, are in Architecture.

License

MIT

Clone this wiki locally