Search by

hr-skills / hrpay

stephanezoa

HRPay PHP SDK

dev-main 2026-09-13 06:38 UTC

This package is auto-updated.

Last update: 2026-09-13 06:40:48 UTC


README

Le SDK PHP officiel pour l'API HRPay : Mobile Money (MTN, Orange...), cartes virtuelles, paiement par carte hébergé, factures, airtime/data, payroll, liens de paiement, webhooks, analytics.

L'idée : tu ne devrais jamais avoir à construire une requête HTTP à la main pour parler à HRPay. Ce SDK type chaque appel, gère les retries et l'idempotence pour toi, et te laisse la main sur ce qui compte vraiment — ton token, ton flux métier.

Sommaire

Installation

composer require hrpay/hrpay

Il te faut PHP 8.1+ et un client HTTP compatible PSR-18. Si tu en as déjà un dans ton projet (Guzzle, Symfony HttpClient...), le SDK le trouvera tout seul via php-http/discovery — rien à configurer. Sinon :

composer require guzzlehttp/guzzle

Authentification

Deux étapes, et c'est toi qui gardes la main sur le token — le SDK ne le stocke jamais et ne le rafraîchit jamais tout seul :

  1. Tu échanges ta clé publique + ta clé secrète contre un token de transaction.
  2. Tu instancies le client avec ta clé publique et ce token.
require 'vendor/autoload.php';

use HRPay\HRPay;
use HRPay\Exceptions\HRPayException;

$publicKey = 'hrsk_pk_test_xxxxx'; // hrsk_pk_test_... en sandbox, hrsk_pk_live_... en prod
$secretKey = 'hrsk_sk_test_xxxxx';

// Étape 1 — le sandbox est détecté automatiquement dès que la clé publique contient "_test_"
$token = HRPay::getTransactionToken($publicKey, $secretKey);

// Étape 2 — c'est parti
$hrpay = new HRPay(publicKey: $publicKey, secretKey: $secretKey, token: $token);

try {
    $response = $hrpay->cashIn->mobileMoney(
        phone: '677123456',
        operator: 'MTN',
        amount: 5000,
        currency: 'XAF',
        country: 'CM',
        // idempotencyKey: '...' // optionnel, généré automatiquement si tu l'omets
    );

    print_r($response);

} catch (\HRPay\Exceptions\ValidationException $e) {
    echo "Erreur de validation : " . $e->getMessage() . "\n";
    print_r($e->getErrors());
} catch (\HRPay\Exceptions\AuthenticationException $e) {
    echo "Clé API invalide.\n";
} catch (HRPayException $e) {
    echo "Erreur HRPay : " . $e->getMessage() . "\n";
}

Chaque requête part déjà avec X-Public-Key, Authorization: Bearer <token> et X-Transaction-Token — tu n'as rien d'autre à ajouter.

Un token, ça expire. Le jour où tu prends une AuthenticationException, la réponse est toujours la même : redemande-en un via HRPay::getTransactionToken().

Sandbox vs Production

Rien à changer dans ton code : dès que ta clé publique contient _test_, le SDK préfixe automatiquement chaque appel avec /sandbox.

$hrpay->isSandbox(); // true si ta publicKey contient "_test_"

Tu peux aussi forcer explicitement l'URL de base (le SDK est assez malin pour ne pas doubler le /sandbox si ton URL le contient déjà) :

$hrpay = new HRPay(
    publicKey: $publicKey,
    secretKey: $secretKey,
    token: $token,
    baseUrl: 'https://api.hrskills-pay.com/sandbox',
);

Un piège à connaître sur le module bills : les 4 sous-modules par facturier (eneo, camwater, canalPlus, customs) n'existent tout simplement pas en sandbox — ils répondent 404 sous /sandbox. Pour tester tes intégrations factures en sandbox, passe par les méthodes génériques bills->lookup() et bills->pay() à la place.

Intégration Laravel

Le SDK embarque un ServiceProvider auto-découvert. Publie sa config :

php artisan vendor:publish --tag=hrpay-config

Puis renseigne ton .env :

PSP_HRPAY_KEY="hrsk_pk_test_..."
PSP_HRPAY_SECRET="hrsk_sk_test_..."
PSP_HRPAY_BASE_URL="https://api.hrskills-pay.com/sandbox"
PSP_HRPAY_TOKEN="..." # optionnel : un token statique, si tu ne veux pas le gérer toi-même

Le client est enregistré en singleton (alias hrpay), donc tu peux directement l'injecter :

public function collect(HRPay $hrpay)
{
    return $hrpay->cashIn->mobileMoney(
        phone: '677123456',
        operator: 'MTN',
        amount: 5000,
        currency: 'XAF',
        country: 'CM',
    );
}

Une nuance importante : Laravel n'obtient pas le transaction_token à ta place. C'est toujours à ton application de l'obtenir, de le stocker (cache, session, whatever te convient) et de le fournir — soit via PSP_HRPAY_TOKEN, soit en reconstruisant le client toi-même après l'avoir rafraîchi.

Modules disponibles

Tout part de $hrpay. Chaque propriété ci-dessous est un module dédié à une famille de fonctionnalités :

Propriété Rôle
$hrpay->cashIn Encaisser (push Mobile Money)
$hrpay->cashOut Décaisser vers un wallet Mobile Money
$hrpay->transactions Consulter/lister/rembourser des transactions
$hrpay->wallet Solde et mouvements du wallet marchand
$hrpay->airtime Recharge de crédit téléphonique
$hrpay->data Envoi de forfaits internet
$hrpay->bills Paiement de factures (ENEO, CAMWATER, Canal+, Douanes)
$hrpay->cards Cartes virtuelles Visa/Mastercard (Cartevo)
$hrpay->cardPayments Paiement par carte via checkout hébergé (E-NKAP)
$hrpay->payroll Paie en masse (import, exécution, rapport)
$hrpay->commissions Barème et historique des commissions VAS
$hrpay->paymentLinks Création de liens de paiement partageables
$hrpay->countries Pays et opérateurs supportés par ton compte
$hrpay->analytics Statistiques (résumé, volumes, revenus)
$hrpay->webhooks Liste des événements + vérification de signature
$hrpay->auth Aide à l'obtention manuelle d'un token

cashIn — Encaisser (Mobile Money)

C'est le point d'entrée le plus courant : tu déclenches un push Mobile Money, et le client valide le paiement lui-même sur son téléphone.

$hrpay->cashIn->mobileMoney(
    phone: '677123456',
    operator: 'MTN',        // MTN, ORANGE, CAMTEL, NEXTTEL, WAVE, MPESA, AIRTEL, MOOV, TMONEY, FLOOZ...
    amount: 5000,
    currency: 'XAF',
    country: 'CM',
    description: 'Facture #1234',
    metadata: ['order_id' => 1234],
    reference: 'ORDER-1234',        // ta propre référence, idéalement idempotente
    idempotencyKey: null,           // header HTTP Idempotency-Key, généré pour toi si tu l'omets
);

// Alias utilisé par certaines intégrations existantes — fait exactement la même chose
$hrpay->cashIn->initiate(/* mêmes paramètres */);

Une fois le paiement initié, deux façons de savoir ce qu'il devient : interroger $hrpay->transactions->status($reference) toi-même, ou écouter les webhooks payment.succeeded / payment.failed (bien plus propre en production).

cashOut — Décaisser (Mobile Money)

Le symétrique du précédent : tu envoies de l'argent vers le wallet Mobile Money de quelqu'un.

$hrpay->cashOut->mobileMoney(
    phone: '677123456',
    operator: 'ORANGE',
    amount: 15000,
    currency: 'XAF',
    country: 'CM',
    description: 'Remboursement commande #42',
    reference: 'REFUND-42',
);

transactions — Suivi des transactions

// Statut d'un paiement par référence
$hrpay->transactions->status('ORDER-1234');

// Détail complet
$hrpay->transactions->get('ORDER-1234');

// Liste filtrée
$hrpay->transactions->list(
    status: 'SUCCESS',           // PENDING, SUCCESS, FAILED, REFUNDED, HOLD
    type: 'CASHIN',               // CASHIN ou CASHOUT
    operator: 'MTN',
    from: '2026-01-01',
    to: '2026-01-31',
    page: 1,
    limit: 20,
);

// Rembourser une transaction réussie
$hrpay->transactions->refund('ORDER-1234');

// Barème de frais
$hrpay->transactions->fees();

Pas d'endpoint webhook sous la main pour l'instant, ou tu veux juste un script simple qui attend la fin d'un paiement ? poll() bloque jusqu'à un statut terminal :

$result = $hrpay->transactions->poll(
    reference: 'ORDER-1234',
    interval: 2.0,      // secondes entre chaque vérification
    timeout: 120.0,      // délai maximum total
    onStatus: function (string $status, int $attempt) {
        echo "Tentative {$attempt} : {$status}\n";
    },
);

Ceci dit, dès que tu peux exposer un webhook, préfère toujours ça au polling — c'est plus rapide et ça ne consomme pas de quota API pour rien.

wallet — Solde et mouvements

$hrpay->wallet->balance();

$hrpay->wallet->movements(
    from: '2026-01-01',
    to: '2026-01-31',
    page: 1,
    limit: 50,
);

airtime — Recharge de crédit

$hrpay->airtime->recharge(
    operator: 'MTN',          // MTN, ORANGE, CAMTEL, NEXTTEL
    phone: '677123456',
    amount: 1000,
    customerPhone: '677000000', // téléphone du payeur, pour le reçu / la référence
    customerEmail: 'client@example.com',
    reference: 'RECHARGE-1',
);

// Plusieurs recharges en un seul appel
$hrpay->airtime->batch([
    ['operator' => 'MTN', 'phone' => '677111111', 'amount' => 500],
    ['operator' => 'ORANGE', 'phone' => '699222222', 'amount' => 1000],
]);

// Dénominations disponibles par opérateur
$hrpay->airtime->offers();

À savoir : ORANGE et NEXTTEL passent la validation côté SDK mais sont actuellement désactivés côté fournisseur — même en production, ils renvoient une erreur. Ce n'est pas un bug du SDK, c'est l'état réel de l'intégration en amont.

data — Forfaits internet

$hrpay->data->packages(operator: 'MTN'); // forfaits disponibles

$hrpay->data->send(
    operator: 'MTN',
    phone: '677123456',
    amount: 1000,
);

bills — Paiement de factures

Deux façons de payer une facture : les méthodes génériques (indépendantes du facturier, celles à utiliser en sandbox) et 4 sous-modules spécifiques par facturier (production uniquement).

use HRPay\Enums\Biller;

// Générique — marche en sandbox comme en production
$hrpay->bills->lookup(Biller::ENEO_PREPAID, '01234567890');
$hrpay->bills->pay(
    biller: Biller::CANALPLUS,
    accountNumber: 'DECODER123',
    amount: 10000,
    customerPhone: '677123456',
);

// ENEO (électricité) — production uniquement
$hrpay->bills->eneo->invoice('01234567890');          // facture postpayée
$hrpay->bills->eneo->prepaidLookup('01234567890');     // consultation compteur prépayé
$hrpay->bills->eneo->prepaid('01234567890', 5000);     // achat de jetons prépayés
$hrpay->bills->eneo->postpaid('01234567890', 15000);   // paiement facture postpayée

// CAMWATER (eau) — production uniquement
$hrpay->bills->camwater->invoice('METER123');
$hrpay->bills->camwater->pay('METER123', 8000);

// Canal+ (abonnement TV) — production uniquement
$hrpay->bills->canalPlus->lookup('DECODER123');
$hrpay->bills->canalPlus->pay('DECODER123', 12000);

// Douanes — production uniquement
$hrpay->bills->customs->get('DECLARATION123');
$hrpay->bills->customs->pay('DECLARATION123', 250000);

Petit piège classique : Biller::ENEO tout seul résout vers le postpayé. Si tu veux du prépayé, utilise explicitement Biller::ENEO_PREPAID.

cards — Cartes virtuelles (Cartevo)

La suite complète pour émettre et gérer des cartes virtuelles Visa/Mastercard, alimentées par un wallet USD dédié. Le flux typique ressemble à ça :

  1. cards->customers->create() — tu onboardes le porteur de carte (KYC)
  2. Tu attends que son statut passe à ENROLLED
  3. cards->wallet->fund() — tu approvisionnes le wallet USD depuis ton wallet principal (XAF)
  4. cards->virtualCards->create() — la carte est émise
use HRPay\Enums\IdDocumentType;
use HRPay\Enums\CardBrand;

// --- KYC du client ---
$customer = $hrpay->cards->customers->create(
    firstName: 'Jean',
    lastName: 'Kamga',
    email: 'jean.kamga@example.com',
    country: 'Cameroon',
    countryIsoCode: 'CM',
    countryPhoneCode: '+237',
    phoneNumber: '677123456',
    street: 'Rue 123',
    city: 'Douala',
    state: 'Littoral',
    postalCode: '00000',
    identificationNumber: '123456789',
    idDocumentType: IdDocumentType::NIN,
    dateOfBirth: '1990-01-01',
);

$hrpay->cards->customers->list(status: 'ENROLLED', search: 'Kamga');
$hrpay->cards->customers->get($customerId);
$hrpay->cards->customers->cards($customerId);        // cartes du client
$hrpay->cards->customers->transactions($customerId); // transactions, toutes cartes confondues

// --- Wallet USD (ce qui finance les cartes) ---
$hrpay->cards->wallet->balance();
$hrpay->cards->wallet->pricing();                     // plan tarifaire + taux de change du jour

// Un aperçu avant de valider — fournis amountUsd OU amountXaf, pas les deux
$hrpay->cards->wallet->quote(amountXaf: 65000, direction: 'fund');

$hrpay->cards->wallet->fund(amountXaf: 65000);         // XAF -> wallet USD
$hrpay->cards->wallet->withdraw(amountUsd: 50);        // wallet USD -> wallet principal

// --- Les cartes elles-mêmes ---
$card = $hrpay->cards->virtualCards->create(
    customerId: $customerId,
    brand: CardBrand::VISA,
    amount: 20,               // charge initiale en USD
    nameOnCard: 'JEAN KAMGA',
    label: 'Carte marketing',
);

$hrpay->cards->virtualCards->list(customerId: $customerId, status: 'ACTIVE');
$hrpay->cards->virtualCards->get($cardId, reveal: false, sync: true); // reveal=true exige une autorisation renforcée
$hrpay->cards->virtualCards->topup($cardId, 10);
$hrpay->cards->virtualCards->withdraw($cardId, 5);
$hrpay->cards->virtualCards->freeze($cardId);          // réversible
$hrpay->cards->virtualCards->unfreeze($cardId);
$hrpay->cards->virtualCards->cancel($cardId);          // définitif — pas de retour en arrière
$hrpay->cards->virtualCards->transactions($cardId, page: 0, type: 'AUTHORIZATION'); // seule pagination 0-indexée du SDK

// Tu préfères attendre que ça se stabilise plutôt que de gérer un webhook ?
$hrpay->cards->virtualCards->poll($cardId);

cardPayments — Paiement par carte hébergé (E-NKAP)

Un checkout hébergé classique : tu crées la session, tu rediriges le client vers l'URL renvoyée, puis tu attends le statut final.

$payment = $hrpay->cardPayments->create(
    amount: 25000,
    currency: 'XAF',
    description: 'Commande #987',
    lang: 'fr',
    returnUrl: 'https://monsite.com/retour',
    cancelUrl: 'https://monsite.com/annulation',
    customer: ['name' => 'Jean Kamga', 'email' => 'jean.kamga@example.com', 'phone' => '677123456'],
);

// Redirige ton client vers $payment['checkout_url']

$hrpay->cardPayments->get($reference);

// CAPTURED est l'état qui compte vraiment — n'attends jamais SETTLED, il arrive bien plus tard (voire jamais dans certains cas)
$hrpay->cardPayments->poll($reference, onStatus: fn($status, $attempt) => error_log("{$status} ({$attempt})"));

// Le wallet "card-collect" (E-NKAP) s'authentifie différemment : un JWT du dashboard, pas ton Bearer habituel
$hrpay->cardPayments->getCardCollectBalance($jwt);
$hrpay->cardPayments->requestWithdrawal(amount: 100000, currency: 'XAF', jwt: $jwt);
$hrpay->cardPayments->listWithdrawalRequests(status: 'PENDING', jwt: $jwt);
$hrpay->cardPayments->listChargebacks(status: 'UNDER_REVIEW', jwt: $jwt);

payroll — Paie en masse

// 1. Tu importes le lot (recipients en tableau, fichier CSV/XLSX en base64, ou CSV brut — au choix)
$batch = $hrpay->payroll->import(
    label: 'Paie Janvier 2026',
    currency: 'XAF',
    recipients: [
        ['phone' => '677111111', 'operator' => 'MTN', 'amount' => 150000, 'name' => 'Employé 1'],
        ['phone' => '699222222', 'operator' => 'ORANGE', 'amount' => 200000, 'name' => 'Employé 2'],
    ],
);
$batchId = $batch['batch_id'] ?? $batch['id'];

// 2. Tu déclenches le versement
$hrpay->payroll->execute($batchId);

// 3. Tu suis le traitement
$hrpay->payroll->status($batchId);

// 4. Tu récupères le rapport détaillé par bénéficiaire
$hrpay->payroll->report($batchId);

// Tous les lots passés
$hrpay->payroll->list(page: 1, limit: 20);

commissions — Commissions revendeur (VAS)

$hrpay->commissions->rates();                              // barème des taux par service VAS

$hrpay->commissions->history(
    service: 'AIRTIME',    // AIRTIME, DATA, ENEO, CAMWATER, CANALPLUS, CUSTOMS
    from: '2026-01-01',
    to: '2026-01-31',
);

$hrpay->commissions->summary(from: '2026-01-01', to: '2026-01-31');

paymentLinks — Liens de paiement

Pour les cas où tu veux juste un lien à envoyer, sans intégration côté client.

use HRPay\Enums\Currency;

$link = $hrpay->paymentLinks->create(
    amount: 10000,
    description: 'Don association',
    currency: Currency::XAF,        // XAF par défaut
    expiresAt: '2026-12-31T23:59:59Z',
);

$hrpay->paymentLinks->list(page: 1, limit: 20);

countries — Pays et opérateurs supportés

// Particularité : ce endpoint veut un JWT du dashboard marchand,
// pas le Bearer token de transaction que tu utilises partout ailleurs.
$hrpay->countries->supported($dashboardJwt);

analytics — Statistiques marchand

$hrpay->analytics->summary();       // vue d'ensemble
$hrpay->analytics->transactions();  // volumétrie
$hrpay->analytics->revenue();       // revenus

webhooks — Événements et vérification de signature

$hrpay->webhooks->events(); // historique récent des livraisons

Règle numéro un des webhooks : ne traite jamais un payload sans avoir vérifié sa signature d'abord.

use HRPay\Resources\Webhooks;

$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HRPAY_SIGNATURE'] ?? '';

$event = Webhooks::constructEvent($payload, $signature, $webhookSecret);
if ($event === null) {
    http_response_code(400);
    exit;
}

// Maintenant tu peux faire confiance à $event['type']
match ($event['type']) {
    'payment.succeeded' => /* ... */ null,
    'payment.failed'     => /* ... */ null,
    default              => null,
};

Le SDK expose aussi HRPay\Utils\WebhookSignature::verifyHeader(), qui vérifie un en-tête horodaté au format t=<timestamp>,v1=<signature> (avec une tolérance de délai configurable, 300s par défaut) :

use HRPay\Utils\WebhookSignature;

try {
    WebhookSignature::verifyHeader(
        payload: $payload,
        signatureHeader: $_SERVER['HTTP_HRPAY_SIGNATURE'] ?? '',
        secret: $webhookSecret,
        tolerance: 300,
    );
    // signature valide
} catch (\RuntimeException $e) {
    http_response_code(400);
    exit;
}

Les deux méthodes existent parce que le format d'en-tête varie selon comment tu es intégré côté HRPay. Utilise celle qui correspond à ce que tu reçois réellement : Webhooks::constructEvent() pour un HMAC simple, WebhookSignature::verifyHeader() pour le format horodaté t=...,v1=....

Types d'événements (HRPay\Enums\WebhookEventType) :

Paiement Carte Paiement carte (E-NKAP)
payment.succeeded card.created card_payment.succeeded
payment.failed card.funded card_payment.failed
payment.hold card.withdrawn card_payment.canceled
payment.refunded card.terminated card_payment.fraud_rejected
— card.transaction.approved card_payment.chargeback_opened
— card.transaction.declined card_payment.chargeback_evidence_needed
— — card_payment.chargeback_won
— — card_payment.chargeback_lost
— — card_balance.withdrawal_completed

Enums de référence

Tous les enums sont des string enum PHP 8.1. Tu peux passer l'instance d'enum ou directement la chaîne équivalente, les deux marchent partout (Operator::MTN ou 'MTN', au choix).

Enum Valeurs
Country CM, SN, CI, NG, GH, KE, TZ, UG, RW, ET, ML, BF, TG, BJ, NE, GW, GN, GM, CF, MZ, ZM, MW
Currency XAF, XOF, NGN, GHS, KES, TZS, UGX, RWF, ETB, MWK, ZMW, GNF, USD, EUR
Operator MTN, ORANGE, CAMTEL, NEXTTEL, WAVE, MPESA, AIRTEL, MOOV, TMONEY, FLOOZ, CELTIIS, CORIS, AFRIMONEY, QMONEY, WLIGDICASH
AirtimeOperator MTN, ORANGE, CAMTEL, NEXTTEL
TransactionStatus PENDING, SUCCESS, FAILED, REFUNDED, HOLD
TransactionDirection CASHIN, CASHOUT
Biller ENEO_PREPAID, ENEO_VOUCHER, ENEO_POSTPAID, ENEO_BILL, ENEO, CAMWATER, CAMWATER_BILL, CANALPLUS, CANALPLUS_SUB, CUSTOMS, CUSTOMS_PAY, DOUANES
VasService AIRTIME, DATA, ENEO, CAMWATER, CANALPLUS, CUSTOMS
CardBrand VISA, MASTERCARD
CardStatus PENDING, ACTIVE, FROZEN, SUSPENDED, TERMINATED, FAILED
CardTransactionType AUTHORIZATION, SETTLEMENT, FUNDING, WITHDRAWAL, DECLINE, REVERSAL, REFUND, CROSS-BORDER, TERMINATION
CardPaymentStatus PENDING, AUTHORIZED, CAPTURED, SETTLED, REFUNDED, FAILED, CANCELED
ChargebackStatus RECEIVED, UNDER_REVIEW, EVIDENCE_SUBMITTED, WON, LOST
KycStatus PENDING_REVIEW, ENROLLING, ENROLLED, REJECTED_PROVIDER, REJECTED_LOCAL
IdDocumentType NIN, PASSPORT, VOTERS_CARD, DRIVERS_LICENSE
Environment sandbox, production
WebhookEventType voir le tableau des événements ci-dessus

Gestion des erreurs

Toutes les exceptions du SDK implémentent HRPay\Exceptions\HRPayException. Si tu ne veux pas distinguer les cas, un seul catch (HRPayException $e) suffit.

Exception Quand Ce qu'elle t'apporte en plus
ValidationException Réponse 422 getErrors(): array — le détail par champ
AuthenticationException Réponse 401 / 403 —
RateLimitException Réponse 429 getRetryAfter(): ?int (en secondes)
NetworkException Erreur réseau, retries épuisés —
ApiErrorException Tout le reste getStatusCode(): ?int
use HRPay\Exceptions\{ValidationException, AuthenticationException, RateLimitException, NetworkException, HRPayException};

try {
    $hrpay->cashIn->mobileMoney(/* ... */);
} catch (ValidationException $e) {
    print_r($e->getErrors());
} catch (RateLimitException $e) {
    sleep($e->getRetryAfter() ?? 5);
} catch (AuthenticationException $e) {
    // ton token a expiré : redemandes-en un via HRPay::getTransactionToken()
} catch (NetworkException | HRPayException $e) {
    echo $e->getMessage();
}

Fonctionnalités transverses

Quelques comportements qui tournent en arrière-plan, sans que tu aies à y penser :

  • Retries automatiques — chaque requête réessaie toute seule (backoff exponentiel : 100ms, 200ms, 400ms...) sur erreur réseau, 429, 503+ ou 408. Tu contrôles ça via maxRetries au constructeur (2 par défaut).
  • Idempotence — la plupart des méthodes d'écriture acceptent un idempotencyKey optionnel (header Idempotency-Key). Tu l'omets ? Une valeur est générée pour toi.
  • Polling — transactions->poll(), cardPayments->poll() et cards->virtualCards->poll() bloquent jusqu'au statut terminal, avec callback onStatus(status, attempt), timeout et nombre max de tentatives configurables. Pratique quand un webhook n'est pas envisageable, mais pas un substitut idéal.
  • Injection HTTP — tu peux fournir tes propres implémentations PSR-18/17 au constructeur (httpClient, requestFactory, streamFactory). Utile pour tes tests (voir Http\Mock\Client, déjà en require-dev) ou pour brancher ton propre client HTTP (logging, proxy, etc.).

Tests

composer install
vendor/bin/phpunit          # tests unitaires, tout est mocké — aucun appel réseau
vendor/bin/phpstan analyse  # analyse statique

Le test end-to-end (tests/Integration/E2ETest.php) appelle le vrai sandbox HRPay. Il a besoin des variables d'environnement PSP_HRPAY_KEY, PSP_HRPAY_SECRET et PSP_HRPAY_BASE_URL — si elles ne sont pas définies, il est automatiquement skipped, pas d'échec surprise en CI.