├── tests ├── Resource │ ├── Payment │ │ ├── __snapshots__ │ │ │ ├── DebtorTest__testDebtor__1.json │ │ │ ├── CreditorTest__testCreditor__1.json │ │ │ └── PaymentTest__testPayment__1.json │ │ ├── DebtorTest.php │ │ ├── CreditorTest.php │ │ └── PaymentTest.php │ └── Search │ │ ├── __snapshots__ │ │ └── SearchResultTest__testSearchResult__1.json │ │ └── SearchResultTest.php ├── __snapshots__ │ ├── PayconiqApiClientTest__testGetPayment__1.json │ ├── PayconiqApiClientTest__testSearchPayments__1.json │ └── PayconiqApiClientTest__testRequestPayment__1.json ├── Request │ ├── __snapshots__ │ │ ├── SearchPaymentsTest__testSearchPayments__1.json │ │ └── RequestPaymentTest__testRequestPayment__1.json │ ├── SearchPaymentsTest.php │ └── RequestPaymentTest.php ├── Exception │ └── PayconiqApiExceptionTest.php ├── HeaderChecker │ ├── PayconiqJtiCheckerTest.php │ ├── PayconiqPathCheckerTest.php │ ├── PayconiqIssCheckerTest.php │ ├── PayconiqSubCheckerTest.php │ └── PayconiqIssuedAtCheckerTest.php ├── PayconiqCallbackSignatureVerifierTest.php ├── PayconiqQrCodeGeneratorTest.php └── PayconiqApiClientTest.php ├── .gitignore ├── src ├── Enum │ ├── QrImageFormat.php │ ├── QrImageColor.php │ ├── QrImageSize.php │ └── PaymentStatus.php ├── Exception │ ├── PayconiqJWKSetException.php │ ├── PayconiqCallbackSignatureVerificationException.php │ ├── PayconiqBaseException.php │ └── PayconiqApiException.php ├── HeaderChecker │ ├── PayconiqJtiChecker.php │ ├── PayconiqPathChecker.php │ ├── PayconiqIssChecker.php │ ├── PayconiqSubChecker.php │ └── PayconiqIssuedAtChecker.php ├── Resource │ ├── Payment │ │ ├── Debtor.php │ │ ├── Creditor.php │ │ └── Payment.php │ └── Search │ │ └── SearchResult.php ├── Request │ ├── SearchPayments.php │ └── RequestPayment.php ├── PayconiqQrCodeGenerator.php ├── PayconiqApiClient.php └── PayconiqCallbackSignatureVerifier.php ├── examples ├── CancelPaymentExample.php ├── RefundPaymentExample.php ├── GetPaymentExample.php ├── SearchPaymentsExample.php ├── CreatePaymentExample.php ├── QRCodeGeneratorExample.php └── VerifyCallbackSignatureExample.php ├── codecov.yml ├── phpunit.xml.dist ├── LICENSE ├── Makefile ├── .github └── workflows │ └── tests.yaml ├── composer.json ├── phpmd.xml ├── phpcs.xml └── README.md /tests/Resource/Payment/__snapshots__/DebtorTest__testDebtor__1.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": null, 3 | "iban": null 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /bin/.phpunit.result.cache 3 | .phpunit.cache/ 4 | .phpunit.result.cache 5 | .idea/ 6 | composer.lock 7 | -------------------------------------------------------------------------------- /tests/__snapshots__/PayconiqApiClientTest__testGetPayment__1.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "Authorization": "Bearer some-api-key" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/Enum/QrImageFormat.php: -------------------------------------------------------------------------------- 1 | cancelPayment('5bdb1685b93d1c000bde96f2'); 16 | -------------------------------------------------------------------------------- /examples/RefundPaymentExample.php: -------------------------------------------------------------------------------- 1 | refundPayment('5bdb1685b93d1c000bde96f2'); 16 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | target: 90% 9 | threshold: 2% 10 | informational: false 11 | patch: 12 | default: 13 | target: 90% 14 | threshold: 2% 15 | informational: false 16 | 17 | ignore: 18 | - "vendor/**" 19 | - "examples/**" 20 | - "build/**" 21 | - "coverage/**" 22 | - "tests/**/Fixtures/**" 23 | -------------------------------------------------------------------------------- /src/Enum/PaymentStatus.php: -------------------------------------------------------------------------------- 1 | getPayment('5bdb1685b93d1c000bde96f2'); 16 | var_dump($payment); 17 | -------------------------------------------------------------------------------- /src/Exception/PayconiqBaseException.php: -------------------------------------------------------------------------------- 1 | useProd; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/SearchPaymentsExample.php: -------------------------------------------------------------------------------- 1 | searchPayments($search); 19 | var_dump($searchResult); 20 | -------------------------------------------------------------------------------- /tests/Resource/Payment/DebtorTest.php: -------------------------------------------------------------------------------- 1 | assertNull($debtor->getName()); 19 | $this->assertNull($debtor->getIban()); 20 | 21 | $this->assertMatchesJsonSnapshot($debtor->toArray()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Resource/Payment/__snapshots__/PaymentTest__testPayment__1.json: -------------------------------------------------------------------------------- 1 | { 2 | "paymentId": "payment-id", 3 | "createdAt": "2022-01-25T00:00:00+00:00", 4 | "status": "SUCCEEDED", 5 | "reference": null, 6 | "amount": 10, 7 | "currency": "EUR", 8 | "creditor": { 9 | "callbackUrl": null, 10 | "profileId": "profile-id", 11 | "merchantId": "merchant-id", 12 | "name": "name", 13 | "iban": "iban" 14 | }, 15 | "debtor": { 16 | "name": null, 17 | "iban": null 18 | }, 19 | "expiresAt": null, 20 | "description": null, 21 | "bulkId": null, 22 | "selfLink": null, 23 | "deepLink": null, 24 | "qrLink": null, 25 | "refundLink": null, 26 | "checkoutLink": null 27 | } 28 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | tests 12 | 13 | 14 | 15 | 16 | src 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/Exception/PayconiqApiExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('a message', $exception->getPayconiqMessage()); 20 | $this->assertEquals('code', $exception->getPayconiqCode()); 21 | $this->assertEquals('trace-id', $exception->getTraceId()); 22 | $this->assertEquals('span-id', $exception->getSpanId()); 23 | $this->assertTrue($exception->isUseProd()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/CreatePaymentExample.php: -------------------------------------------------------------------------------- 1 | setCallbackUrl('https://mywebsite.com/api/payconiq-webhook'); 20 | $requestPayment1->setReference('ref123456'); 21 | $requestPayment1->setPosId('POS00001'); 22 | 23 | $payment1 = $client->requestPayment($requestPayment1); 24 | var_dump($payment1); 25 | 26 | $requestPayment2 = new RequestPayment( 27 | 2500 // = € 25 28 | ); 29 | $payment2 = $client->requestPayment($requestPayment2); 30 | var_dump($payment2); 31 | -------------------------------------------------------------------------------- /tests/Resource/Search/__snapshots__/SearchResultTest__testSearchResult__1.json: -------------------------------------------------------------------------------- 1 | { 2 | "size": 1, 3 | "totalPages": 2, 4 | "totalElements": 3, 5 | "number": 4, 6 | "details": [ 7 | { 8 | "paymentId": "new-payment-id", 9 | "createdAt": "2022-01-26T00:00:00+00:00", 10 | "status": "PENDING", 11 | "amount": 20, 12 | "currency": "USD", 13 | "creditor": { 14 | "profileId": "profile-id2", 15 | "merchantId": "merchant-id", 16 | "name": "name", 17 | "iban": "iban", 18 | "callbackUrl": null 19 | }, 20 | "debtor": { 21 | "name": "name", 22 | "iban": "iban" 23 | }, 24 | "expiresAt": "2022-01-26T00:00:00+00:00", 25 | "description": "desc", 26 | "bulkId": "bulk-id", 27 | "qrLink": null, 28 | "deepLink": null, 29 | "reference": null, 30 | "refundLink": null, 31 | "selfLink": null, 32 | "checkoutLink": null 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/HeaderChecker/PayconiqJtiChecker.php: -------------------------------------------------------------------------------- 1 | checker = new PayconiqJtiChecker(); 18 | } 19 | 20 | public function testCheckHeader() 21 | { 22 | $this->checker->checkHeader('valid'); 23 | $this->expectException(InvalidHeaderException::class); 24 | $this->checker->checkHeader(null); 25 | } 26 | 27 | public function testSupportedHeader() 28 | { 29 | $this->assertEquals('https://payconiq.com/jti', $this->checker->supportedHeader()); 30 | } 31 | 32 | public function testProtectedHeaderOnly() 33 | { 34 | $this->assertFalse($this->checker->protectedHeaderOnly()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Resource/Payment/CreditorTest.php: -------------------------------------------------------------------------------- 1 | profileId = 'profile-id'; 17 | $obj->merchantId = 'merchant-id'; 18 | $obj->name = 'name'; 19 | $obj->iban = 'iban'; 20 | 21 | $creditor = Creditor::createFromObject($obj); 22 | 23 | $this->assertEquals('profile-id', $creditor->getProfileId()); 24 | $this->assertEquals('merchant-id', $creditor->getMerchantId()); 25 | $this->assertEquals('name', $creditor->getName()); 26 | $this->assertEquals('iban', $creditor->getIban()); 27 | $this->assertNull($creditor->getCallbackUrl()); 28 | 29 | $this->assertMatchesJsonSnapshot($creditor->toArray()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/QRCodeGeneratorExample.php: -------------------------------------------------------------------------------- 1 | getQrLink() 18 | $qrLink = 'https://portal.payconiq.com/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fpay%2F2%2F73a222xxxxxxxxx00964'; 19 | $customizedQRLink = PayconiqQrCodeGenerator::customizePaymentQrLink( 20 | $qrLink, 21 | QrImageFormat::PNG, 22 | QrImageSize::EXTRA_LARGE, 23 | QrImageColor::BLACK, 24 | ); 25 | var_dump($customizedQRLink); 26 | 27 | //Example 2: static QR code 28 | $staticQRLink = PayconiqQrCodeGenerator::generateStaticQRCodeLink('abc123', 'POS00001'); 29 | var_dump($staticQRLink); 30 | -------------------------------------------------------------------------------- /src/Exception/PayconiqApiException.php: -------------------------------------------------------------------------------- 1 | payconiqMessage; 25 | } 26 | 27 | public function getPayconiqCode(): ?string 28 | { 29 | return $this->payconiqCode; 30 | } 31 | 32 | public function getTraceId(): ?string 33 | { 34 | return $this->traceId; 35 | } 36 | 37 | public function getSpanId(): ?string 38 | { 39 | return $this->spanId; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/HeaderChecker/PayconiqPathChecker.php: -------------------------------------------------------------------------------- 1 | checker = new PayconiqPathChecker(); 18 | } 19 | 20 | public function testCheckHeader() 21 | { 22 | $this->checker->checkHeader('http://url.be'); 23 | $this->checker->checkHeader('https://url.be'); 24 | $this->expectException(InvalidHeaderException::class); 25 | $this->checker->checkHeader('invalid-url'); 26 | } 27 | 28 | public function testSupportedHeader() 29 | { 30 | $this->assertEquals('https://payconiq.com/path', $this->checker->supportedHeader()); 31 | } 32 | 33 | public function testProtectedHeaderOnly() 34 | { 35 | $this->assertFalse($this->checker->protectedHeaderOnly()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Resource/Payment/Debtor.php: -------------------------------------------------------------------------------- 1 | name ?? null, 27 | iban: $obj->iban ?? null, 28 | ); 29 | } 30 | 31 | public function toArray(): array 32 | { 33 | return [ 34 | 'name' => $this->name, 35 | 'iban' => $this->iban, 36 | ]; 37 | } 38 | 39 | public function getName(): ?string 40 | { 41 | return $this->name; 42 | } 43 | 44 | public function getIban(): ?string 45 | { 46 | return $this->iban; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Setup ———————————————————————————————————————————————————————————————————————— 2 | SHELL = bash 3 | EXEC_PHP = symfony php 4 | COMPOSER = symfony composer 5 | 6 | ## —— 🐝 The Makefile 🐝 ——————————————————————————————————— 7 | help: ## Outputs this help screen 8 | @grep -E '(^[a-zA-Z0-9_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}{printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/' 9 | 10 | ## —— Composer 🧙‍️ ———————————————————————————————————————————————————————————— 11 | install: composer.lock ## Install vendors according to the current composer.lock file 12 | $(COMPOSER) install --no-progress --no-suggest --prefer-dist --optimize-autoloader 13 | 14 | update: composer.json ## Update vendors according to the composer.json file 15 | $(COMPOSER) update 16 | 17 | ## —— Tests ✅ ————————————————————————————————————————————————————————————————— 18 | test: test-phpunit test-phpmd test-phpcs ## Launch all tests 19 | 20 | test-phpunit: ## Run phpunit tests 21 | ${EXEC_PHP} ./vendor/bin/phpunit --stop-on-failure --testsuite unit-tests 22 | 23 | test-phpmd: 24 | ${EXEC_PHP} ./vendor/bin/phpmd src/ ansi phpmd.xml 25 | 26 | test-phpcs: 27 | ${EXEC_PHP} ./vendor/bin/phpcs src/ tests/ --colors -p 28 | -------------------------------------------------------------------------------- /examples/VerifyCallbackSignatureExample.php: -------------------------------------------------------------------------------- 1 | headers->get('signature'); 16 | $signature = 'eyJ0eXAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxbg8xg'; 17 | 18 | //POST body (payload) 19 | $payload = '{"paymentId":"5bdb1685b93d1c000bde96f2","amount":100,"createdAt":"2020-12-01T10:22:40.487Z","expireAt":"2020-12-01T10:42:40.487Z","status":"EXPIRED","currency":"EUR"}'; 20 | 21 | $payconiqCallbackSignatureVerifier = new PayconiqCallbackSignatureVerifier($paymentProfileId, null, null, false); 22 | 23 | echo $payconiqCallbackSignatureVerifier->isValid($signature, $payload) ? 'valid' : 'invalid'; 24 | 25 | var_dump($payconiqCallbackSignatureVerifier->loadAndVerifyJWS($signature, $payload)); 26 | -------------------------------------------------------------------------------- /tests/Request/SearchPaymentsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(new Carbon('2022-01-25'), $searchPayments->getFrom()); 20 | 21 | $this->assertNull($searchPayments->getTo()); 22 | $searchPayments->setTo(new \DateTime('2022-01-26')); 23 | $this->assertEquals(new Carbon('2022-01-26'), $searchPayments->getTo()); 24 | 25 | $this->assertEmpty($searchPayments->getPaymentStatuses()); 26 | $searchPayments->setPaymentStatuses([PaymentStatus::EXPIRED, PaymentStatus::CANCELLED]); 27 | $this->assertEquals([PaymentStatus::EXPIRED, PaymentStatus::CANCELLED], $searchPayments->getPaymentStatuses()); 28 | 29 | $this->assertNull($searchPayments->getReference()); 30 | $searchPayments->setReference('ref'); 31 | $this->assertEquals('ref', $searchPayments->getReference()); 32 | 33 | $this->assertMatchesJsonSnapshot($searchPayments->toArray()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/HeaderChecker/PayconiqIssChecker.php: -------------------------------------------------------------------------------- 1 | checker = new PayconiqIssChecker(); 18 | } 19 | 20 | public function testCheckHeader() 21 | { 22 | $this->checker->checkHeader('Payconiq'); 23 | $this->expectException(InvalidHeaderException::class); 24 | $this->expectExceptionMessage('"https://payconiq.com/iss" should be "Payconiq"'); 25 | $this->checker->checkHeader('InvalidPayconiq'); 26 | } 27 | 28 | public function testCheckHeaderWhenNotString() 29 | { 30 | $this->expectException(InvalidHeaderException::class); 31 | $this->expectExceptionMessage('"https://payconiq.com/iss" must be a string.'); 32 | $this->checker->checkHeader([]); 33 | } 34 | 35 | public function testSupportedHeader() 36 | { 37 | $this->assertEquals('https://payconiq.com/iss', $this->checker->supportedHeader()); 38 | } 39 | 40 | public function testProtectedHeaderOnly() 41 | { 42 | $this->assertFalse($this->checker->protectedHeaderOnly()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/HeaderChecker/PayconiqSubCheckerTest.php: -------------------------------------------------------------------------------- 1 | checker = new PayconiqSubChecker('abcdef'); 18 | } 19 | 20 | public function testCheckHeader() 21 | { 22 | $this->checker->checkHeader('abcdef'); 23 | $this->expectException(InvalidHeaderException::class); 24 | $this->expectExceptionMessage('"https://payconiq.com/sub" should match the Payment profile ID'); 25 | $this->checker->checkHeader('fedcba'); 26 | } 27 | 28 | public function testCheckHeaderWhenNotString() 29 | { 30 | $this->expectException(InvalidHeaderException::class); 31 | $this->expectExceptionMessage('"https://payconiq.com/sub" must be a string.'); 32 | $this->checker->checkHeader([]); 33 | } 34 | 35 | public function testSupportedHeader() 36 | { 37 | $this->assertEquals('https://payconiq.com/sub', $this->checker->supportedHeader()); 38 | } 39 | 40 | public function testProtectedHeaderOnly() 41 | { 42 | $this->assertFalse($this->checker->protectedHeaderOnly()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/HeaderChecker/PayconiqSubChecker.php: -------------------------------------------------------------------------------- 1 | paymentProfileId) { 38 | throw new InvalidHeaderException( 39 | message: sprintf('"%s" should match the Payment profile ID', self::HEADER_NAME), 40 | header: self::HEADER_NAME, 41 | value: $value, 42 | ); 43 | } 44 | } 45 | 46 | public function supportedHeader(): string 47 | { 48 | return self::HEADER_NAME; 49 | } 50 | 51 | public function protectedHeaderOnly(): bool 52 | { 53 | return false; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | operating-system: ['ubuntu-latest'] 12 | php-versions: ['8.2', '8.3', '8.4'] 13 | phpunit-versions: ['latest'] 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup PHP, with composer and extensions 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: ${{ matrix.php-versions }} 23 | extensions: mbstring, xml, ctype, iconv, intl, ast 24 | coverage: xdebug 25 | 26 | - name: Validate composer.json and composer.lock 27 | run: composer validate 28 | 29 | - name: Install dependencies 30 | if: steps.composer-cache.outputs.cache-hit != 'true' 31 | run: composer install --prefer-dist --no-progress --no-suggest 32 | 33 | - name: Run phpunit 34 | run: php vendor/bin/phpunit --stop-on-failure --testsuite unit-tests --coverage-clover clover.xml 35 | 36 | - name: Run mess detector 37 | run: php vendor/bin/phpmd src/ ansi phpmd.xml 38 | 39 | - name: Run code sniffer 40 | run: php vendor/bin/phpcs src/ tests/ --colors -p 41 | 42 | - name: Send test coverage to codecov.io 43 | if: matrix.php-versions == '8.4' 44 | uses: codecov/codecov-action@v5 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | files: clover.xml 48 | flags: php-${{ matrix.php-versions }} 49 | fail_ci_if_error: true 50 | verbose: true 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optiosteam/payconiq-client-php", 3 | "description": "Payconiq API client library for PHP developed by Optios.", 4 | "keywords": [ 5 | "payconiq", 6 | "client", 7 | "api", 8 | "php" 9 | ], 10 | "support": { 11 | "issues": "https://github.com/optiosteam/payconiq-client-php/issues", 12 | "source": "https://github.com/optiosteam/payconiq-client-php" 13 | }, 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Optios BV", 18 | "email": "dev@optios.net" 19 | } 20 | ], 21 | "require": { 22 | "php": ">=8.2", 23 | "composer/ca-bundle": "^1.1", 24 | "ext-json": "*", 25 | "guzzlehttp/guzzle": "^7.5", 26 | "nesbot/carbon": "^3.0", 27 | "symfony/cache": "^5.4|^6.0|^7.0", 28 | "web-token/jwt-library": "^4.0", 29 | "league/uri": "^7.5", 30 | "league/uri-components": "^7.5", 31 | "phpseclib/phpseclib": "^3.0" 32 | }, 33 | "conflict": { 34 | "web-token/jwt-checker": "*", 35 | "web-token/jwt-signature": "*", 36 | "web-token/jwt-signature-algorithm-ecdsa": "*" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Optios\\Payconiq\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Tests\\Optios\\Payconiq\\": "tests/" 46 | } 47 | }, 48 | "require-dev": { 49 | "squizlabs/php_codesniffer": "^3.5", 50 | "phpmd/phpmd": "^2.9", 51 | "phpunit/phpunit": "^11.2", 52 | "spatie/phpunit-snapshot-assertions": "^5.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Resource/Search/SearchResultTest.php: -------------------------------------------------------------------------------- 1 | [ 18 | json_decode( 19 | '{ 20 | "paymentId": "new-payment-id", 21 | "createdAt": "2022-01-26T00:00:00+00:00", 22 | "status": "PENDING", 23 | "amount": 20, 24 | "currency": "USD", 25 | "creditor": { 26 | "profileId": "profile-id2", 27 | "merchantId": "merchant-id", 28 | "name": "name", 29 | "iban": "iban" 30 | }, 31 | "debtor": { 32 | "name": "name", 33 | "iban": "iban" 34 | }, 35 | "expiresAt": "2022-01-26T00:00:00+00:00", 36 | "description": "desc", 37 | "bulkId": "bulk-id", 38 | "selfLink": "some-uri", 39 | "deepLink": "some-uri", 40 | "qrLink": "some-uri", 41 | "refundLink": "some-uri" 42 | }', 43 | true, 44 | ), 45 | ], 46 | 'size' => 1, 47 | 'totalPages' => 2, 48 | 'totalElements' => 3, 49 | 'number' => 4, 50 | ]))); 51 | 52 | $this->assertEquals(1, $searchResult->getSize()); 53 | $this->assertEquals(2, $searchResult->getTotalPages()); 54 | $this->assertEquals(3, $searchResult->getTotalElements()); 55 | $this->assertEquals(4, $searchResult->getNumber()); 56 | $this->assertMatchesJsonSnapshot($searchResult->toArray()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Resource/Payment/Creditor.php: -------------------------------------------------------------------------------- 1 | profileId, 30 | merchantId: $obj->merchantId, 31 | name: $obj->name, 32 | iban: $obj->iban, 33 | callbackUrl: $obj->callbackUrl ?? null, 34 | ); 35 | } 36 | 37 | public function toArray(): array 38 | { 39 | return [ 40 | 'profileId' => $this->profileId, 41 | 'merchantId' => $this->merchantId, 42 | 'name' => $this->name, 43 | 'iban' => $this->iban, 44 | 'callbackUrl' => $this->callbackUrl, 45 | ]; 46 | } 47 | 48 | public function getProfileId(): string 49 | { 50 | return $this->profileId; 51 | } 52 | 53 | public function getMerchantId(): string 54 | { 55 | return $this->merchantId; 56 | } 57 | 58 | public function getName(): string 59 | { 60 | return $this->name; 61 | } 62 | 63 | public function getIban(): string 64 | { 65 | return $this->iban; 66 | } 67 | 68 | public function getCallbackUrl(): ?string 69 | { 70 | return $this->callbackUrl; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Request/RequestPaymentTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(1, $requestPayment->getAmount()); 18 | 19 | $this->assertEquals('EUR', $requestPayment->getCurrency()); 20 | 21 | $this->assertNull($requestPayment->getCallbackUrl()); 22 | $requestPayment->setCallbackUrl('some-uri'); 23 | $this->assertEquals('some-uri', $requestPayment->getCallbackUrl()); 24 | 25 | $this->assertNull($requestPayment->getReference()); 26 | $requestPayment->setReference('ref'); 27 | $this->assertEquals('ref', $requestPayment->getReference()); 28 | 29 | $this->assertNull($requestPayment->getDescription()); 30 | $requestPayment->setDescription('description'); 31 | $this->assertEquals('description', $requestPayment->getDescription()); 32 | 33 | $this->assertNull($requestPayment->getBulkId()); 34 | $requestPayment->setBulkId('bulk-id'); 35 | $this->assertEquals('bulk-id', $requestPayment->getBulkId()); 36 | 37 | $this->assertNull($requestPayment->getShopId()); 38 | $requestPayment->setShopId('shop-id'); 39 | $this->assertEquals('shop-id', $requestPayment->getShopId()); 40 | 41 | $this->assertNull($requestPayment->getShopName()); 42 | $requestPayment->setShopName('shop-name'); 43 | $this->assertEquals('shop-name', $requestPayment->getShopName()); 44 | 45 | $this->assertNull($requestPayment->getReturnUrl()); 46 | $requestPayment->setReturnUrl('some-uri'); 47 | $this->assertEquals('some-uri', $requestPayment->getReturnUrl()); 48 | 49 | $this->assertMatchesJsonSnapshot($requestPayment->toArray()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /phpmd.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | Created with the PHP Coding Standard Generator. http://edorian.github.com/php-coding-standard-generator/ 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/HeaderChecker/PayconiqIssuedAtChecker.php: -------------------------------------------------------------------------------- 1 | setMicroseconds(0)->gt(CarbonImmutable::now('UTC'))) { 46 | throw new InvalidHeaderException( 47 | message: 'The JWT is issued in the future.', 48 | header: self::HEADER_NAME, 49 | value: $value, 50 | ); 51 | } 52 | } 53 | 54 | public function supportedHeader(): string 55 | { 56 | return self::HEADER_NAME; 57 | } 58 | 59 | public function protectedHeaderOnly(): bool 60 | { 61 | return false; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | PSR-12 + focused Generic/Squiz rules 4 | 5 | src 6 | tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /tests/HeaderChecker/PayconiqIssuedAtCheckerTest.php: -------------------------------------------------------------------------------- 1 | checker = new PayconiqIssuedAtChecker(); 20 | } 21 | 22 | #[DoesNotPerformAssertions] 23 | public function testCheckHeader() 24 | { 25 | $this->checker->checkHeader('2023-08-25T08:28:21.675129Z'); 26 | } 27 | 28 | #[DoesNotPerformAssertions] 29 | public function testCheckHeaderNanoSeconds() 30 | { 31 | $this->checker->checkHeader('2023-08-25T08:28:21.675129286Z'); 32 | } 33 | 34 | #[DoesNotPerformAssertions] 35 | public function testCheckHeaderNanoSecondsOtherTimeZone() 36 | { 37 | $this->checker->checkHeader('2023-08-25T08:28:21.675129286+02:00'); 38 | } 39 | 40 | public function testCheckHeaderException() 41 | { 42 | $this->expectException(InvalidHeaderException::class); 43 | $this->expectExceptionMessage('"https://payconiq.com/iat" has an invalid date format'); 44 | $this->checker->checkHeader('Invalid date'); 45 | } 46 | 47 | public function testCheckHeaderWhenInFuture() 48 | { 49 | $this->expectException(InvalidHeaderException::class); 50 | $this->expectExceptionMessage('The JWT is issued in the future.'); 51 | $this->checker->checkHeader(Carbon::tomorrow()->format(PayconiqIssuedAtChecker::IAT_FORMAT)); 52 | } 53 | 54 | public function testCheckHeaderWhenInFutureOtherFormat() 55 | { 56 | $this->expectException(InvalidHeaderException::class); 57 | $this->expectExceptionMessage('The JWT is issued in the future.'); 58 | $this->checker->checkHeader('3023-08-25T08:28:21.675129286Z'); 59 | } 60 | 61 | public function testSupportedHeader() 62 | { 63 | $this->assertEquals('https://payconiq.com/iat', $this->checker->supportedHeader()); 64 | } 65 | 66 | public function testProtectedHeaderOnly() 67 | { 68 | $this->assertFalse($this->checker->protectedHeaderOnly()); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/Resource/Payment/PaymentTest.php: -------------------------------------------------------------------------------- 1 | paymentId = 'payment-id'; 19 | $obj->createdAt = '2022-01-25'; 20 | $obj->status = 'SUCCEEDED'; 21 | $obj->amount = 10; 22 | $obj->creditor = new \stdClass(); 23 | $obj->creditor->profileId = 'profile-id'; 24 | $obj->creditor->merchantId = 'merchant-id'; 25 | $obj->creditor->name = 'name'; 26 | $obj->creditor->iban = 'iban'; 27 | $obj->debtor = new \stdClass(); 28 | 29 | $payment = Payment::createFromObject($obj); 30 | 31 | $this->assertEquals('payment-id', $payment->getPaymentId()); 32 | $this->assertEquals(new Carbon('2022-01-25'), $payment->getCreatedAt()); 33 | $this->assertNull($payment->getExpiresAt()); 34 | $this->assertEquals('EUR', $payment->getCurrency()); 35 | $this->assertEquals(PaymentStatus::SUCCEEDED, $payment->getStatus()); 36 | $this->assertEquals( 37 | [ 38 | 'profileId' => 'profile-id', 39 | 'merchantId' => 'merchant-id', 40 | 'name' => 'name', 41 | 'iban' => 'iban', 42 | 'callbackUrl' => null, 43 | ], 44 | $payment->getCreditor()->toArray(), 45 | ); 46 | $this->assertEquals( 47 | [ 48 | 'name' => null, 49 | 'iban' => null, 50 | ], 51 | $payment->getDebtor()->toArray(), 52 | ); 53 | $this->assertEquals(10, $payment->getAmount()); 54 | $this->assertNull($payment->getDescription()); 55 | $this->assertNull($payment->getBulkId()); 56 | $this->assertNull($payment->getSelfLink()); 57 | $this->assertNull($payment->getDeepLink()); 58 | $this->assertNull($payment->getQrLink()); 59 | $this->assertNull($payment->getRefundLink()); 60 | $this->assertNull($payment->getCheckoutLink()); 61 | $this->assertNull($payment->getReference()); 62 | 63 | $this->assertMatchesJsonSnapshot($payment->toArray()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Resource/Search/SearchResult.php: -------------------------------------------------------------------------------- 1 | $details 14 | */ 15 | private function __construct( 16 | private int $size, 17 | private int $totalPages, 18 | private int $totalElements, 19 | private int $number, 20 | private array $details, 21 | ) { 22 | } 23 | 24 | /** 25 | * @throws \JsonException 26 | * @throws \Exception 27 | */ 28 | public static function createFromResponse(ResponseInterface $response): self 29 | { 30 | $response = json_decode( 31 | json: $response->getBody()->getContents(), 32 | associative: false, 33 | flags: JSON_THROW_ON_ERROR, 34 | ); 35 | 36 | $details = []; 37 | 38 | if (false === empty($response->details)) { 39 | foreach ($response->details as $paymentDetail) { 40 | $details[] = Payment::createFromObject($paymentDetail); 41 | } 42 | } 43 | 44 | return new self( 45 | size: $response->size, 46 | totalPages: $response->totalPages, 47 | totalElements: $response->totalElements, 48 | number: $response->number, 49 | details: $details, 50 | ); 51 | } 52 | 53 | public function toArray(): array 54 | { 55 | return [ 56 | 'size' => $this->size, 57 | 'totalPages' => $this->totalPages, 58 | 'totalElements' => $this->totalElements, 59 | 'number' => $this->number, 60 | 'details' => array_map( 61 | callback: static fn(Payment $payment) => $payment->toArray(), 62 | array: $this->details, 63 | ), 64 | ]; 65 | } 66 | 67 | public function getSize(): int 68 | { 69 | return $this->size; 70 | } 71 | 72 | public function getTotalPages(): int 73 | { 74 | return $this->totalPages; 75 | } 76 | 77 | public function getTotalElements(): int 78 | { 79 | return $this->totalElements; 80 | } 81 | 82 | public function getNumber(): int 83 | { 84 | return $this->number; 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | public function getDetails(): array 91 | { 92 | return $this->details; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Request/SearchPayments.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | private array $paymentStatuses = []; 20 | private ?string $reference = null; 21 | 22 | public function __construct(\DateTimeInterface $from) 23 | { 24 | $this->setFrom($from); 25 | } 26 | 27 | public function toArray(): array 28 | { 29 | $array = [ 30 | 'from' => $this->from->format(self::SEARCH_DATE_FORMAT), 31 | ]; 32 | 33 | if (null !== $this->to) { 34 | $array['to'] = $this->to->format(self::SEARCH_DATE_FORMAT); 35 | } 36 | 37 | if (false === empty($this->paymentStatuses)) { 38 | $array['paymentStatuses'] = array_map( 39 | callback: static fn(PaymentStatus $status) => $status->value, 40 | array: $this->paymentStatuses, 41 | ); 42 | } 43 | 44 | if (null !== $this->reference && '' !== $this->reference) { 45 | $array['reference'] = $this->reference; 46 | } 47 | 48 | return $array; 49 | } 50 | 51 | public function getFrom(): CarbonImmutable 52 | { 53 | return $this->from; 54 | } 55 | 56 | public function setFrom(\DateTimeInterface $from): self 57 | { 58 | if (false === $from instanceof CarbonImmutable) { 59 | $from = new CarbonImmutable($from); 60 | } 61 | 62 | $this->from = $from->setTimezone('UTC'); 63 | 64 | return $this; 65 | } 66 | 67 | public function getTo(): ?CarbonImmutable 68 | { 69 | return $this->to; 70 | } 71 | 72 | public function setTo(?\DateTimeInterface $to): self 73 | { 74 | if (null === $to) { 75 | $this->to = null; 76 | 77 | return $this; 78 | } 79 | 80 | if (false === $to instanceof CarbonImmutable) { 81 | $to = new CarbonImmutable($to); 82 | } 83 | 84 | $this->to = $to->setTimezone('UTC'); 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * @return array 91 | */ 92 | public function getPaymentStatuses(): array 93 | { 94 | return $this->paymentStatuses; 95 | } 96 | 97 | /** 98 | * @param array $paymentStatuses 99 | */ 100 | public function setPaymentStatuses(array $paymentStatuses): self 101 | { 102 | $this->paymentStatuses = array_map( 103 | callback: static fn($status) => $status instanceof PaymentStatus 104 | ? $status 105 | : PaymentStatus::from((string) $status), 106 | array: $paymentStatuses, 107 | ); 108 | return $this; 109 | } 110 | 111 | public function getReference(): ?string 112 | { 113 | return $this->reference; 114 | } 115 | 116 | public function setReference(?string $reference): self 117 | { 118 | $this->reference = $reference; 119 | 120 | return $this; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Request/RequestPayment.php: -------------------------------------------------------------------------------- 1 | amount = $amount; 25 | $this->currency = $currency; 26 | } 27 | 28 | public static function createForStaticQR(int $amount, string $posId, string $currency = 'EUR'): self 29 | { 30 | $self = new self( 31 | amount: $amount, 32 | currency: $currency, 33 | ); 34 | $self->setPosId($posId); 35 | 36 | return $self; 37 | } 38 | 39 | public function toArray(): array 40 | { 41 | $array = [ 42 | 'amount' => $this->amount, 43 | 'currency' => $this->currency, 44 | 'callbackUrl' => $this->callbackUrl, 45 | 'reference' => $this->reference, 46 | 'description' => $this->description, 47 | 'bulkId' => $this->bulkId, 48 | 'posId' => $this->posId, 49 | 'shopId' => $this->shopId, 50 | 'shopName' => $this->shopName, 51 | 'returnUrl' => $this->returnUrl, 52 | ]; 53 | 54 | return array_filter($array); 55 | } 56 | 57 | public function getAmount(): int 58 | { 59 | return $this->amount; 60 | } 61 | 62 | public function getCurrency(): string 63 | { 64 | return $this->currency; 65 | } 66 | 67 | public function getCallbackUrl(): ?string 68 | { 69 | return $this->callbackUrl; 70 | } 71 | 72 | public function setCallbackUrl(?string $callbackUrl): self 73 | { 74 | $this->callbackUrl = $callbackUrl; 75 | 76 | return $this; 77 | } 78 | 79 | public function getReference(): ?string 80 | { 81 | return $this->reference; 82 | } 83 | 84 | public function setReference(?string $reference): self 85 | { 86 | $this->reference = $reference; 87 | 88 | return $this; 89 | } 90 | 91 | public function getDescription(): ?string 92 | { 93 | return $this->description; 94 | } 95 | 96 | public function setDescription(?string $description): self 97 | { 98 | $this->description = $description; 99 | 100 | return $this; 101 | } 102 | 103 | public function getBulkId(): ?string 104 | { 105 | return $this->bulkId; 106 | } 107 | 108 | public function setBulkId(?string $bulkId): self 109 | { 110 | $this->bulkId = $bulkId; 111 | 112 | return $this; 113 | } 114 | 115 | public function getPosId(): ?string 116 | { 117 | return $this->posId; 118 | } 119 | 120 | public function setPosId(?string $posId): self 121 | { 122 | $this->posId = $posId; 123 | 124 | return $this; 125 | } 126 | 127 | public function getShopId(): ?string 128 | { 129 | return $this->shopId; 130 | } 131 | 132 | public function setShopId(?string $shopId): self 133 | { 134 | $this->shopId = $shopId; 135 | 136 | return $this; 137 | } 138 | 139 | public function getShopName(): ?string 140 | { 141 | return $this->shopName; 142 | } 143 | 144 | public function setShopName(?string $shopName): self 145 | { 146 | $this->shopName = $shopName; 147 | 148 | return $this; 149 | } 150 | 151 | public function getReturnUrl(): ?string 152 | { 153 | return $this->returnUrl; 154 | } 155 | 156 | public function setReturnUrl(?string $returnUrl): self 157 | { 158 | $this->returnUrl = $returnUrl; 159 | 160 | return $this; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/PayconiqQrCodeGenerator.php: -------------------------------------------------------------------------------- 1 | getQrLink() 23 | * e.g. https://portal.payconiq.com/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fpay%2F2%2F38e150xxxxxxxxxxa0efe45 24 | * 25 | * Used for: 26 | * - Terminal & Display (https://developer.payconiq.com/online-payments-dock/#payconiq-instore-v3-terminal-display) 27 | * - Custom Online (https://developer.payconiq.com/online-payments-dock/#payconiq-online-v3-custom-online) 28 | */ 29 | public static function customizePaymentQrLink( 30 | string $qrLink, 31 | QrImageFormat $format = QrImageFormat::PNG, 32 | QrImageSize $size = QrImageSize::SMALL, 33 | QrImageColor $color = QrImageColor::MAGENTA, 34 | ): string { 35 | return (string) Modifier::from(Http::new($qrLink)) 36 | ->mergeQueryParameters([ 37 | 'f' => $format->value, 38 | 's' => $size->value, 39 | 'cl' => $color->value, 40 | ]) 41 | ->getUri(); 42 | } 43 | 44 | /** 45 | * Used for: 46 | * - Static QR Sticker (https://developer.payconiq.com/online-payments-dock/#payconiq-instore-v3-static-qr-sticker) 47 | */ 48 | public static function generateStaticQRCodeLink( 49 | string $paymentProfileId, 50 | string $posId, 51 | QrImageFormat $format = QrImageFormat::PNG, 52 | QrImageSize $size = QrImageSize::SMALL, 53 | QrImageColor $color = QrImageColor::MAGENTA, 54 | ): string { 55 | $urlPayload = self::LOCATION_URL_SCHEME_STATIC . $paymentProfileId . '/' . $posId; 56 | 57 | $uri = Modifier::from(Http::new(self::QRCODE_GENERATOR_URL)) 58 | ->mergeQueryParameters(['c' => $urlPayload]) 59 | ->getUri(); 60 | 61 | return self::customizePaymentQrLink((string) $uri, $format, $size, $color); 62 | } 63 | 64 | /** 65 | * Used for: 66 | * - Receipt (https://developer.payconiq.com/online-payments-dock/#payconiq-instore-v3-receipt) 67 | * - Invoice (https://developer.payconiq.com/online-payments-dock/#payconiq-invoice-v3-invoice) 68 | * - Top-up (https://developer.payconiq.com/online-payments-dock/#payconiq-online-v3-top-up) 69 | * 70 | * @SuppressWarnings(PHPMD.CyclomaticComplexity) 71 | */ 72 | public static function generateQRCodeWithMetadata( 73 | string $paymentProfileId, 74 | ?string $description, 75 | ?int $amount, 76 | ?string $reference, 77 | QrImageFormat $format = QrImageFormat::PNG, 78 | QrImageSize $size = QrImageSize::SMALL, 79 | QrImageColor $color = QrImageColor::MAGENTA, 80 | ): string { 81 | $payloadUri = Http::new(self::LOCATION_URL_SCHEME_METADATA . $paymentProfileId); 82 | 83 | $query = []; 84 | if (null !== $description && $description !== '') { 85 | if (strlen($description) > 35) { 86 | throw new \InvalidArgumentException('Description max length is 35 characters'); 87 | } 88 | 89 | $query['D'] = $description; 90 | } 91 | 92 | if (null !== $amount) { 93 | if ($amount < 1 || $amount > 999999) { 94 | throw new \InvalidArgumentException('Amount must be between 1 - 999999 Euro cents'); 95 | } 96 | 97 | $query['A'] = $amount; 98 | } 99 | 100 | if (null !== $reference && $reference !== '') { 101 | if (strlen($reference) > 35) { 102 | throw new \InvalidArgumentException('Reference max length is 35 characters'); 103 | } 104 | 105 | $query['R'] = $reference; 106 | } 107 | 108 | if (false === empty($query)) { 109 | $payloadUri = Modifier::from($payloadUri)->mergeQueryParameters($query)->getUri(); 110 | } 111 | 112 | $uri = Modifier::from(Http::new(self::QRCODE_GENERATOR_URL)) 113 | ->mergeQueryParameters(['c' => (string) $payloadUri]) 114 | ->getUri(); 115 | 116 | return self::customizePaymentQrLink((string) $uri, $format, $size, $color); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/PayconiqCallbackSignatureVerifierTest.php: -------------------------------------------------------------------------------- 1 | paymentProfileId = 'profileId'; 29 | $this->httpClient = $this->createMock(Client::class); 30 | $this->cache = $this->createMock(FilesystemAdapter::class); 31 | $this->useProd = false; 32 | 33 | $this->payconiqCallbackSignatureVerifier = new PayconiqCallbackSignatureVerifier( 34 | paymentProfileId: $this->paymentProfileId, 35 | httpClient: $this->httpClient, 36 | cache: $this->cache, 37 | useProd: $this->useProd, 38 | ); 39 | 40 | $this->jwsLoader = $this->createMock(JWSLoader::class); 41 | 42 | // Because the jwsLoader is not injected in to the verifier, 43 | // we need to do some magic to make sure we can mock it. 44 | // Ideally this should be refactored to DI. 45 | $class = new \ReflectionClass(PayconiqCallbackSignatureVerifier::class); 46 | $property = $class->getProperty('jwsLoader'); 47 | $property->setAccessible(true); 48 | $property->setValue($this->payconiqCallbackSignatureVerifier, $this->jwsLoader); 49 | } 50 | 51 | public function testIsValid(): void 52 | { 53 | $url = 'https://jwks.preprod.bancontact.net'; 54 | $cacheKey = 'payconiq_certificates_' . md5($url); 55 | $jwkSetJson = json_encode(['keys' => [['kty' => 'string']]]); 56 | $this->cache 57 | ->expects($this->once()) 58 | ->method('get') 59 | ->with($cacheKey, function (ItemInterface $item) use ($url) { 60 | }) 61 | ->willReturn($jwkSetJson); 62 | 63 | $this->jwsLoader 64 | ->expects($this->once()) 65 | ->method('loadAndVerifyWithKeySet') 66 | ->with('some-token', JWKSet::createFromJson($jwkSetJson), 0, null); 67 | 68 | $this->assertTrue($this->payconiqCallbackSignatureVerifier->isValid('some-token')); 69 | } 70 | 71 | public function testIsInvalid(): void 72 | { 73 | $url = 'https://jwks.preprod.bancontact.net'; 74 | $cacheKey = 'payconiq_certificates_' . md5($url); 75 | $jwkSetJson = json_encode(['keys' => [['kty' => 'string']]]); 76 | $this->cache 77 | ->expects($this->once()) 78 | ->method('get') 79 | ->with($cacheKey, function (ItemInterface $item) use ($url) { 80 | }) 81 | ->willReturn($jwkSetJson); 82 | 83 | $this->jwsLoader 84 | ->expects($this->once()) 85 | ->method('loadAndVerifyWithKeySet') 86 | ->with('some-token', JWKSet::createFromJson($jwkSetJson), 0, null) 87 | ->willThrowException(new \Exception('Unable to load and verify the token.')); 88 | 89 | $this->assertFalse($this->payconiqCallbackSignatureVerifier->isValid('some-token')); 90 | } 91 | 92 | public function testLoadAndVerifyJWS(): void 93 | { 94 | $url = 'https://jwks.preprod.bancontact.net'; 95 | $cacheKey = 'payconiq_certificates_' . md5($url); 96 | $jwkSetJson = json_encode(['keys' => [['kty' => 'string']]]); 97 | $this->cache 98 | ->expects($this->once()) 99 | ->method('get') 100 | ->with($cacheKey, function (ItemInterface $item) use ($url) { 101 | }) 102 | ->willReturn($jwkSetJson); 103 | 104 | $this->jwsLoader 105 | ->expects($this->once()) 106 | ->method('loadAndVerifyWithKeySet') 107 | ->with('some-token', JWKSet::createFromJson($jwkSetJson), 0, null) 108 | ->willReturn(new JWS('the-payload', 'encoded-payload')); 109 | 110 | $this->assertInstanceOf( 111 | JWS::class, 112 | $this->payconiqCallbackSignatureVerifier->loadAndVerifyJWS('some-token'), 113 | ); 114 | } 115 | 116 | public function testLoadAndVerifyJWSItShouldThrow(): void 117 | { 118 | $url = 'https://jwks.preprod.bancontact.net'; 119 | $cacheKey = 'payconiq_certificates_' . md5($url); 120 | $jwkSetJson = json_encode(['keys' => [['kty' => 'string']]]); 121 | $this->cache 122 | ->expects($this->once()) 123 | ->method('get') 124 | ->with($cacheKey, function (ItemInterface $item) use ($url) { 125 | }) 126 | ->willReturn($jwkSetJson); 127 | 128 | $this->jwsLoader 129 | ->expects($this->once()) 130 | ->method('loadAndVerifyWithKeySet') 131 | ->with('some-token', JWKSet::createFromJson($jwkSetJson), 0, null) 132 | ->willThrowException(new \Exception('Unable to load and verify the token.')); 133 | 134 | $this->expectException(PayconiqCallbackSignatureVerificationException::class); 135 | //phpcs:disable 136 | $this->expectExceptionMessage( 137 | 'Something went wrong while loading and verifying the JWS. Error: Unable to load and verify the token.', 138 | ); 139 | //phpcs:enable 140 | 141 | $this->payconiqCallbackSignatureVerifier->loadAndVerifyJWS('some-token'); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Resource/Payment/Payment.php: -------------------------------------------------------------------------------- 1 | getBody()->getContents(), 44 | associative: false, 45 | flags: JSON_THROW_ON_ERROR, 46 | ); 47 | 48 | return self::createFromObject($decoded); 49 | } 50 | 51 | /** 52 | * @throws \Exception 53 | * @deprecated Use createFromObject() instead. 54 | */ 55 | public static function createFromStdClass(\stdClass $response): self 56 | { 57 | return self::createFromObject($response); 58 | } 59 | 60 | /** 61 | * @throws \ValueError if status is unknown (via PaymentStatus::from). 62 | * @throws \Exception if date parsing fails. 63 | * 64 | * @SuppressWarnings(PHPMD.CyclomaticComplexity) 65 | * @SuppressWarnings(PHPMD.NPathComplexity) 66 | * 67 | * phpcs:disable Generic.Metrics.CyclomaticComplexity 68 | */ 69 | public static function createFromObject(object $obj): self 70 | { 71 | $expiresAt = (isset($obj->expiresAt) && $obj->expiresAt !== '') 72 | ? new CarbonImmutable((string) $obj->expiresAt) 73 | : null; 74 | 75 | $description = isset($obj->description) ? (string) $obj->description : null; 76 | $bulkId = isset($obj->bulkId) ? (string) $obj->bulkId : null; 77 | $reference = isset($obj->reference) ? (string) $obj->reference : null; 78 | 79 | $creditor = isset($obj->creditor) 80 | ? Creditor::createFromObject($obj->creditor) 81 | : null; 82 | 83 | $debtor = isset($obj->debtor) 84 | ? Debtor::createFromObject($obj->debtor) 85 | : null; 86 | 87 | $selfLink = $obj->_links?->self?->href ?? null; 88 | $deepLink = $obj->_links?->deeplink?->href ?? null; 89 | $qrLink = $obj->_links?->qrcode?->href ?? null; 90 | $refundLink = $obj->_links?->refund?->href ?? null; 91 | $checkoutLink = $obj->_links?->checkout?->href ?? null; 92 | 93 | return new self( 94 | paymentId: $obj->paymentId, 95 | createdAt: new CarbonImmutable($obj->createdAt), 96 | status: PaymentStatus::from($obj->status), 97 | amount: $obj->amount, 98 | currency: $obj->currency ?? 'EUR', 99 | expiresAt: $expiresAt, 100 | creditor: $creditor, 101 | debtor: $debtor, 102 | description: $description, 103 | bulkId: $bulkId, 104 | selfLink: $selfLink, 105 | deepLink: $deepLink, 106 | qrLink: $qrLink, 107 | refundLink: $refundLink, 108 | checkoutLink: $checkoutLink, 109 | reference: $reference, 110 | ); 111 | } 112 | // phpcs:enable Generic.Metrics.CyclomaticComplexity 113 | 114 | public function toArray(): array 115 | { 116 | return [ 117 | 'paymentId' => $this->paymentId, 118 | 'createdAt' => $this->createdAt->toAtomString(), 119 | 'status' => $this->status->value, 120 | 'amount' => $this->amount, 121 | 'currency' => $this->currency, 122 | 'creditor' => $this->creditor?->toArray(), 123 | 'debtor' => $this->debtor?->toArray(), 124 | 'expiresAt' => $this->expiresAt?->toAtomString(), 125 | 'description' => $this->description, 126 | 'bulkId' => $this->bulkId, 127 | 'selfLink' => $this->selfLink, 128 | 'deepLink' => $this->deepLink, 129 | 'qrLink' => $this->qrLink, 130 | 'refundLink' => $this->refundLink, 131 | 'checkoutLink' => $this->checkoutLink, 132 | 'reference' => $this->reference, 133 | ]; 134 | } 135 | 136 | public function getPaymentId(): string 137 | { 138 | return $this->paymentId; 139 | } 140 | 141 | public function getCreatedAt(): CarbonImmutable 142 | { 143 | return $this->createdAt; 144 | } 145 | 146 | public function getStatus(): PaymentStatus 147 | { 148 | return $this->status; 149 | } 150 | 151 | public function getAmount(): int 152 | { 153 | return $this->amount; 154 | } 155 | 156 | public function getCurrency(): string 157 | { 158 | return $this->currency; 159 | } 160 | 161 | public function getExpiresAt(): ?CarbonImmutable 162 | { 163 | return $this->expiresAt; 164 | } 165 | 166 | public function getCreditor(): ?Creditor 167 | { 168 | return $this->creditor; 169 | } 170 | 171 | public function getDebtor(): ?Debtor 172 | { 173 | return $this->debtor; 174 | } 175 | 176 | public function getDescription(): ?string 177 | { 178 | return $this->description; 179 | } 180 | 181 | public function getBulkId(): ?string 182 | { 183 | return $this->bulkId; 184 | } 185 | 186 | public function getSelfLink(): ?string 187 | { 188 | return $this->selfLink; 189 | } 190 | 191 | public function getDeepLink(): ?string 192 | { 193 | return $this->deepLink; 194 | } 195 | 196 | public function getQrLink(): ?string 197 | { 198 | return $this->qrLink; 199 | } 200 | 201 | public function getRefundLink(): ?string 202 | { 203 | return $this->refundLink; 204 | } 205 | 206 | public function getCheckoutLink(): ?string 207 | { 208 | return $this->checkoutLink; 209 | } 210 | 211 | public function getReference(): ?string 212 | { 213 | return $this->reference; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/PayconiqApiClient.php: -------------------------------------------------------------------------------- 1 | self::TIMEOUT, 42 | RequestOptions::CONNECT_TIMEOUT => self::CONNECT_TIMEOUT, 43 | RequestOptions::VERIFY => CaBundle::getBundledCaBundlePath(), 44 | ]); 45 | } 46 | 47 | $this->apiKey = $apiKey; 48 | $this->httpClient = $httpClient; 49 | $this->useProd = $useProd; 50 | } 51 | 52 | public function getApiEndpointBase(): string 53 | { 54 | return $this->getEndpoint() . self::API_VERSION; 55 | } 56 | 57 | private function getEndpoint(): string 58 | { 59 | return true === $this->useProd ? self::API_ENDPOINT_PRODUCTION : self::API_ENDPOINT_STAGING; 60 | } 61 | 62 | /** 63 | * @throws PayconiqApiException 64 | */ 65 | public function requestPayment(RequestPayment $requestPayment): Payment 66 | { 67 | try { 68 | $uri = $this->getApiEndpointBase() . '/payments' . ($requestPayment->getPosId() ? '/pos' : null); 69 | $response = $this->httpClient->post( 70 | uri: $uri, 71 | options: [ 72 | RequestOptions::HEADERS => [ 73 | 'Authorization' => 'Bearer ' . $this->apiKey, 74 | ], 75 | RequestOptions::JSON => $requestPayment->toArray(), 76 | ], 77 | ); 78 | 79 | return Payment::createFromResponse($response); 80 | } catch (ClientException $e) { 81 | throw $this->convertToPayconiqApiException($e); 82 | } 83 | } 84 | 85 | /** 86 | * @throws PayconiqApiException 87 | */ 88 | public function getPayment(string $paymentId): Payment 89 | { 90 | try { 91 | $response = $this->httpClient->get( 92 | uri: $this->getApiEndpointBase() . '/payments/' . $paymentId, 93 | options: [ 94 | RequestOptions::HEADERS => [ 95 | 'Authorization' => 'Bearer ' . $this->apiKey, 96 | ], 97 | ], 98 | ); 99 | 100 | return Payment::createFromResponse($response); 101 | } catch (ClientException $e) { 102 | throw $this->convertToPayconiqApiException($e); 103 | } 104 | } 105 | 106 | /** 107 | * @throws PayconiqApiException 108 | */ 109 | public function cancelPayment(string $paymentId): bool 110 | { 111 | try { 112 | $this->httpClient->delete( 113 | uri: $this->getApiEndpointBase() . '/payments/' . $paymentId, 114 | options: [ 115 | RequestOptions::HEADERS => [ 116 | 'Authorization' => 'Bearer ' . $this->apiKey, 117 | ], 118 | ], 119 | ); 120 | } catch (ClientException $e) { 121 | throw $this->convertToPayconiqApiException($e); 122 | } 123 | 124 | return true; 125 | } 126 | 127 | /** 128 | * @throws PayconiqApiException 129 | */ 130 | public function searchPayments( 131 | SearchPayments $search, 132 | int $page = 0, 133 | int $size = 50, 134 | ): SearchResult { 135 | try { 136 | $uri = Modifier::from( 137 | Http::new($this->getApiEndpointBase() . '/payments/search'), 138 | ) 139 | ->mergeQueryParameters([ 140 | 'page' => $page, 141 | 'size' => $size, 142 | ]) 143 | ->getUri(); 144 | 145 | $response = $this->httpClient->post( 146 | uri: (string) $uri, 147 | options: [ 148 | RequestOptions::HEADERS => [ 149 | 'Authorization' => 'Bearer ' . $this->apiKey, 150 | ], 151 | RequestOptions::JSON => $search->toArray(), 152 | ], 153 | ); 154 | 155 | return SearchResult::createFromResponse($response); 156 | } catch (ClientException $e) { 157 | throw $this->convertToPayconiqApiException($e); 158 | } 159 | } 160 | 161 | /** 162 | * @throws PayconiqApiException 163 | */ 164 | public function refundPayment(string $paymentId) 165 | { 166 | try { 167 | $this->httpClient->get( 168 | uri: $this->getApiEndpointBase() . '/payments/' . $paymentId . '/debtor/refundIban', 169 | ); 170 | } catch (ClientException $e) { 171 | throw $this->convertToPayconiqApiException($e); 172 | } 173 | 174 | return true; 175 | } 176 | 177 | public function getApiKey(): string 178 | { 179 | return $this->apiKey; 180 | } 181 | 182 | public function setApiKey(string $apiKey): void 183 | { 184 | $this->apiKey = $apiKey; 185 | } 186 | 187 | private function convertToPayconiqApiException(ClientException $e): PayconiqApiException 188 | { 189 | $contents = $e->getResponse()->getBody()->getContents() ?? null; 190 | if (empty($contents)) { 191 | return new PayconiqApiException( 192 | payconiqMessage: null, 193 | payconiqCode: null, 194 | traceId: null, 195 | spanId: null, 196 | isProd: $this->useProd, 197 | message: $e->getMessage(), 198 | code: $e->getCode(), 199 | ); 200 | } 201 | 202 | $message = json_decode($contents); 203 | 204 | return new PayconiqApiException( 205 | payconiqMessage: $message->message ?? null, 206 | payconiqCode: $message->code ?? null, 207 | traceId: $message->traceId ?? null, 208 | spanId: $message->spanId ?? null, 209 | isProd: $this->useProd, 210 | message: $e->getMessage(), 211 | code: $e->getCode(), 212 | ); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/PayconiqCallbackSignatureVerifier.php: -------------------------------------------------------------------------------- 1 | self::TIMEOUT, 60 | RequestOptions::CONNECT_TIMEOUT => self::CONNECT_TIMEOUT, 61 | ]); 62 | } 63 | 64 | if (null === $cache) { 65 | $cache = new FilesystemAdapter(); 66 | } 67 | 68 | $this->httpClient = $httpClient; 69 | $this->cache = $cache; 70 | $this->useProd = $useProd; 71 | 72 | $this->jwsLoader = $this->initializeJwsLoader($paymentProfileId); 73 | } 74 | 75 | private function getCertificatesUrl(): string 76 | { 77 | return true === $this->useProd ? self::CERTIFICATES_PRODUCTION_URL : self::CERTIFICATES_STAGING_URL; 78 | } 79 | 80 | public function isValid(string $token, ?string $payload = null, ?int $signature = 0): bool 81 | { 82 | try { 83 | $token = self::normalizeEcdsaSigIfNeeded($token, 32); 84 | $this->jwsLoader->loadAndVerifyWithKeySet($token, $this->getJWKSet(), $signature, $payload); 85 | } catch (\Throwable $e) { 86 | return false; 87 | } 88 | 89 | return true; 90 | } 91 | 92 | /** 93 | * @throws PayconiqCallbackSignatureVerificationException 94 | */ 95 | public function loadAndVerifyJWS(string $token, ?string $payload = null, ?int $signature = 0): JWS 96 | { 97 | try { 98 | $token = self::normalizeEcdsaSigIfNeeded($token, 32); 99 | return $this->jwsLoader->loadAndVerifyWithKeySet($token, $this->getJWKSet(), $signature, $payload); 100 | } catch (\Throwable $e) { 101 | throw new PayconiqCallbackSignatureVerificationException( 102 | $this->useProd, 103 | sprintf('Something went wrong while loading and verifying the JWS. Error: %s', $e->getMessage()), 104 | $e->getCode(), 105 | $e, 106 | ); 107 | } 108 | } 109 | 110 | /** 111 | * @throws PayconiqJWKSetException 112 | */ 113 | private function getJWKSet(): JWKSet 114 | { 115 | try { 116 | $url = $this->getCertificatesUrl(); 117 | $cacheKey = 'payconiq_certificates_' . md5($url); 118 | 119 | $JWKSetJson = $this->cache->get( 120 | key: $cacheKey, 121 | callback: function (ItemInterface $item) use ($url) { 122 | $item->expiresAfter(CarbonInterval::hour(12)); 123 | 124 | $response = $this->httpClient->get($url); 125 | 126 | return $response->getBody()->getContents(); 127 | }, 128 | ); 129 | 130 | return JWKSet::createFromJson($JWKSetJson); 131 | } catch (\Throwable $e) { 132 | throw new PayconiqJWKSetException( 133 | $this->useProd, 134 | sprintf('Something went wrong while fetching the JWK Set. Error: %s', $e->getMessage()), 135 | $e->getCode(), 136 | $e, 137 | ); 138 | } 139 | } 140 | 141 | /** 142 | * If the compact JWS uses a DER-encoded ECDSA signature, convert it to JOSE raw (r||s). 143 | * $partLen: 32 for ES256, 48 for ES384, 66 for ES512. 144 | */ 145 | private static function normalizeEcdsaSigIfNeeded(string $compactJws, int $partLen = 32): string 146 | { 147 | [$h, $p, $sB64u] = explode('.', $compactJws, 3) + [null, null, null]; 148 | if ($sB64u === null || $sB64u === '') { 149 | return $compactJws; 150 | } 151 | 152 | // base64url decode signature 153 | $pad = (4 - strlen($sB64u) % 4) % 4; 154 | $sig = base64_decode(strtr($sB64u, '-_', '+/') . str_repeat('=', $pad), true); 155 | if ($sig === false) { 156 | return $compactJws; 157 | } 158 | 159 | // already raw r||s of expected length? nothing to do. 160 | if (strlen($sig) === 2 * $partLen) { 161 | return $compactJws; 162 | } 163 | 164 | // Try DER → raw using phpseclib helpers 165 | try { 166 | $rs = EcdsaAsn1::load($sig); // ['r'=>BigInteger,'s'=>BigInteger] 167 | $raw = EcdsaP1363::save($rs['r'], $rs['s'], null, $partLen); // fixed-length P-1363 168 | $sB64u = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); 169 | return "$h.$p.$sB64u"; 170 | } catch (\Throwable) { 171 | // Not DER / not parseable - leave as-is and let the verifier decide 172 | return $compactJws; 173 | } 174 | } 175 | 176 | private function initializeJwsLoader(string $paymentProfileId): JWSLoader 177 | { 178 | return new JWSLoader( 179 | new JWSSerializerManager([ 180 | new CompactSerializer(), 181 | ]), 182 | new JWSVerifier( 183 | new AlgorithmManager([ 184 | new ES256(), 185 | ]), 186 | ), 187 | new HeaderCheckerManager( 188 | [ 189 | new AlgorithmChecker(['ES256']), 190 | new PayconiqSubChecker($paymentProfileId), 191 | new PayconiqIssChecker(), 192 | new PayconiqIssuedAtChecker(), 193 | new PayconiqJtiChecker(), 194 | new PayconiqPathChecker(), 195 | ], 196 | [ 197 | new JWSTokenSupport(), 198 | ], 199 | ), 200 | ); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/optiosteam/payconiq-client-php/actions/workflows/tests.yaml/badge.svg?branch=main)](https://github.com/optiosteam/payconiq-client-php/actions/workflows/tests.yaml) 2 | [![codecov](https://codecov.io/gh/optiosteam/payconiq-client-php/branch/main/graph/badge.svg?token=S62YDUXV7A)](https://codecov.io/gh/optiosteam/payconiq-client-php) 3 | 4 | # PHP Payconiq/Wero/Bancontact API Client (unofficial) 5 | 6 | Supported API version: v3 7 | 8 | Development sponsored by [Optios](https://www.optios.net) 9 | 10 | API Documentation: https://developer.payconiq.com/online-payments-dock/#payment-api-version-3-v3- 11 | 12 | ## Supported API functions 13 | This library provides support for the following Payconiq API (v3) functions: 14 | - Payconiq Instore (V3) - Terminal & Display 15 | - Payconiq Instore (V3) - Static QR Sticker 16 | - Payconiq Instore (V3) - Receipt 17 | - Payconiq Invoice (V3) - Invoice 18 | - Payconiq Online (V3) - Custom Online 19 | - Payconiq Online (V3) - Checkout Flow Online 20 | - Payconiq Online (V3) - App2App Linking 21 | - Payconiq Online (V3) - Top-up 22 | 23 | Not supported yet: 24 | - Loyalty Integration 25 | - Payout Reconciliation API 26 | 27 | ## Installation 28 | 29 | **Requirement**: PHP version >=8.2 30 | 31 | ``` 32 | composer require optiosteam/payconiq-client-php 33 | ``` 34 | 35 | ## Migrating from 1.x to 2.x: Migration Payconiq > WERO/Bancontact 36 | 37 | The code has been updated for PHP 8 (constructor property promotion, enums, immutable with `readonly`, ...) 38 | 39 | All resources (`Payment`, `Creditor`, `Debtor`, `SearchResult`) are now immutable. 40 | 41 | **So if you are migrating your code from 1.x to 2.x, make sure to use the enums for PaymentStatus, QR code size, color & format, instead of the old constants** 42 | 43 | Setters on resources no longer exist. 44 | 45 | From version 2.1.0 onward, `transferAmount`, `tippingAmount` and `totalAmount` have been removed from the Payment resource since they are no longer returned by the API (see [here](https://github.com/optiosteam/payconiq-client-php/issues/18#issuecomment-3306512880)). 46 | 47 | ## Description 48 | This library provides 3 main classes: 49 | - `PayconiqApiClient` 50 | - `PayconiqCallbackSignatureVerifier` 51 | - `PayconiqQrCodeGenerator` 52 | 53 | ### PayconiqApiClient 54 | This is the main class for performing REST calls to the Payconiq API, e.g. create payments, cancel payments, search payments & refund payments. 55 | 56 | In the constructor you have to pass your Payconiq API key, optionally you can also inject your own Guzzle Client and specify if you want to use the production environment of the Payconiq API or the testing (Ext) environment. 57 | ```php 58 | public function __construct(string $apiKey, ClientInterface $httpClient = null, bool $useProd = true) 59 | ``` 60 | 61 | ### PayconiqCallbackSignatureVerifier 62 | This class is used for TLS Two-way TLS Encryption Support (TLS-Mutual Authentication). It verifies the callback body, JSON Web Signature (JWS) and the header fields in the JOSE header. 63 | 64 | In the constructor you have to pass your Payconiq Payment Profile Id, optionally you can also inject your own Guzzle Client and Symfony Cache Adapter and specify if you want to use the production environment of the Payconiq API or the testing (Ext) environment. 65 | ```php 66 | public function __construct(string $paymentProfileId, ClientInterface $httpClient = null, AdapterInterface $cache = null, bool $useProd = true) 67 | ``` 68 | 69 | The cache adapter is used to cache Payconiq's JWKS (JSON Web Key Set). 70 | By default this library will use the `FilesystemAdapter` which will use the file system for caching. 71 | If you'd like to use another caching system, like Redis for example, you can inject your own (e.g. `RedisAdapter`). 72 | 73 | List of Symfony's Cache Adapters: https://symfony.com/doc/current/components/cache.html#available-cache-adapters 74 | 75 | **Note**: when using the `PayconiqCallbackSignatureVerifier`, make sure your server time is correct because the verifier checks the issued-at header timestamp. 76 | 77 | ### PayconiqQrCodeGenerator 78 | This class offers static functions to: 79 | - Customize (color, size, format) QR code links (Used for `Terminal & Display` & `Custom Online`) 80 | - Generate static QR code stickers links (Used for `Static QR Sticker`) 81 | - Generate QR code links with metadata, like: description, amount & reference (Used for `Receipt`, `Invoice` & `Top-up`) 82 | 83 | 84 | ## Some examples 85 | 86 | ### Request payment 87 | ```php 88 | use Optios\Payconiq\PayconiqApiClient; 89 | use Optios\Payconiq\Request\RequestPayment; 90 | 91 | $apiKey = 'MY_PAYCONIQ_API_KEY'; 92 | $client = new PayconiqApiClient($apiKey, null, false); 93 | 94 | $requestPayment = new RequestPayment( 95 | 100 // = € 1 96 | ); 97 | $requestPayment->setCallbackUrl('https://mywebsite.com/api/payconiq-webhook'); 98 | $requestPayment->setReference('ref123456'); 99 | $requestPayment->setPosId('POS00001'); 100 | 101 | $payment = $client->requestPayment($requestPayment); 102 | var_dump($payment); 103 | ``` 104 | 105 | ### Get payment 106 | ```php 107 | use Optios\Payconiq\PayconiqApiClient; 108 | 109 | $apiKey = 'MY_PAYCONIQ_API_KEY'; 110 | $client = new PayconiqApiClient($apiKey, null, false); 111 | 112 | $payment = $client->getPayment('5bdb1685b93d1c000bde96f2'); 113 | var_dump($payment); 114 | ``` 115 | 116 | ### Cancel payment 117 | ```php 118 | use Optios\Payconiq\PayconiqApiClient; 119 | 120 | $apiKey = 'MY_PAYCONIQ_API_KEY'; 121 | $client = new PayconiqApiClient($apiKey, null, false); 122 | 123 | $client->cancelPayment('5bdb1685b93d1c000bde96f2'); 124 | ``` 125 | 126 | ### Search payments 127 | ```php 128 | use Carbon\Carbon; 129 | use Optios\Payconiq\PayconiqApiClient; 130 | use Optios\Payconiq\Request\SearchPayments; 131 | 132 | $apiKey = 'MY_PAYCONIQ_API_KEY'; 133 | $client = new PayconiqApiClient($apiKey, null, false); 134 | 135 | $search = new SearchPayments(new Carbon('2020-12-01 00:00:00')); 136 | $searchResult = $client->searchPayments($search); 137 | var_dump($searchResult); 138 | ``` 139 | 140 | ### Refund payment 141 | ```php 142 | use Optios\Payconiq\PayconiqApiClient; 143 | 144 | $apiKey = 'MY_PAYCONIQ_API_KEY'; 145 | $client = new PayconiqApiClient($apiKey, null, false); 146 | 147 | $client->refundPayment('5bdb1685b93d1c000bde96f2'); 148 | ``` 149 | 150 | ### Verify callback (JWS) 151 | ```php 152 | use Optios\Payconiq\PayconiqCallbackSignatureVerifier; 153 | 154 | $paymentProfileId = '5fxxxxxxxxxxxf581'; //your payconiq payment profile id 155 | 156 | // When Payconiq sends a POST to your webhook endpoint (callbackUrl), take the signature from the request header 157 | // e.g. Symfony: Symfony\Component\HttpFoundation\Request $request->headers->get('signature'); 158 | $signature = 'eyJ0eXAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxbg8xg'; 159 | 160 | //POST body (payload) 161 | $payload = '{"paymentId":"5bdb1685b93d1c000bde96f2","transferAmount":0,"tippingAmount":0,"amount":100,"totalAmount":0,"createdAt":"2020-12-01T10:22:40.487Z","expireAt":"2020-12-01T10:42:40.487Z","status":"EXPIRED","currency":"EUR"}'; 162 | 163 | $payconiqCallbackSignatureVerifier = new PayconiqCallbackSignatureVerifier($paymentProfileId, null, null, false); 164 | 165 | echo $payconiqCallbackSignatureVerifier->isValid($signature, $payload) ? 'valid' : 'invalid'; 166 | 167 | var_dump($payconiqCallbackSignatureVerifier->loadAndVerifyJWS($signature, $payload)); 168 | ``` 169 | 170 | ### QR link generation 171 | ```php 172 | use Optios\Payconiq\Enum\QrImageColor; 173 | use Optios\Payconiq\Enum\QrImageFormat; 174 | use Optios\Payconiq\Enum\QrImageSize; 175 | use Optios\Payconiq\PayconiqQrCodeGenerator; 176 | 177 | //Example 1: customized QR code (defaults are PNG, SMALL, MAGENTO) 178 | //e.g. coming from Optios\Payconiq\Resource\Payment\Payment->getQrLink() 179 | $qrLink = 'https://portal.payconiq.com/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fpay%2F2%2F73a222xxxxxxxxx00964'; 180 | $customizedQRLink = PayconiqQrCodeGenerator::customizePaymentQrLink( 181 | $qrLink, 182 | QrImageFormat::PNG, 183 | QrImageSize::EXTRA_LARGE, 184 | QrImageColor::BLACK, 185 | ); 186 | var_dump($customizedQRLink); 187 | 188 | //Example 2: static QR code 189 | $staticQRLink = PayconiqQrCodeGenerator::generateStaticQRCodeLink('abc123', 'POS00001'); 190 | var_dump($staticQRLink); 191 | ``` 192 | 193 | ## Contributing 194 | Feel free to submit pull requests for improvements & bug fixes. 195 | 196 | please ensure your pull request adheres to the following guidelines: 197 | 198 | * Enter a meaningful pull request description. 199 | * Put a link to each library in your pull request ticket so it's easier to review. 200 | * Use the following format for libraries: [LIBRARY](LINK) - DESCRIPTION. 201 | * Make sure your text editor is set to remove trailing whitespace. 202 | 203 | MIT License 204 | -------------------------------------------------------------------------------- /tests/PayconiqQrCodeGeneratorTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 18 | $expected, 19 | PayconiqQrCodeGenerator::customizePaymentQrLink( 20 | qrLink: $data['link'], 21 | format: $data['format'], 22 | size: $data['size'], 23 | color: $data['color'], 24 | ), 25 | ); 26 | } 27 | 28 | public static function getCustomizePaymentQrLinkData(): array 29 | { 30 | //phpcs:disable 31 | $qrLink = 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fpay%2F2%2F73a222957726d63d26400964'; 32 | 33 | return [ 34 | 'Customize - Default' => [ 35 | 'data' => [ 36 | 'link' => $qrLink, 37 | 'format' => QrImageFormat::PNG, 38 | 'size' => QrImageSize::SMALL, 39 | 'color' => QrImageColor::MAGENTA, 40 | ], 41 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fpay%2F2%2F73a222957726d63d26400964&f=PNG&s=S&cl=magenta', 42 | ], 43 | 'Customize - SVG Medium Black' => [ 44 | 'data' => [ 45 | 'link' => $qrLink, 46 | 'format' => QrImageFormat::SVG, 47 | 'size' => QrImageSize::MEDIUM, 48 | 'color' => QrImageColor::BLACK, 49 | ], 50 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fpay%2F2%2F73a222957726d63d26400964&f=SVG&s=M&cl=black', 51 | ], 52 | 'Customize - PNG Large Magenta' => [ 53 | 'data' => [ 54 | 'link' => $qrLink, 55 | 'format' => QrImageFormat::PNG, 56 | 'size' => QrImageSize::LARGE, 57 | 'color' => QrImageColor::MAGENTA, 58 | ], 59 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fpay%2F2%2F73a222957726d63d26400964&f=PNG&s=L&cl=magenta', 60 | ], 61 | ]; 62 | //phpcs:enable 63 | } 64 | 65 | #[DataProvider('getGenerateStaticQRCodeLinkData')] 66 | public function testGenerateStaticQRCodeLink(array $data, string $expected) 67 | { 68 | $this->assertEquals( 69 | $expected, 70 | PayconiqQrCodeGenerator::generateStaticQRCodeLink( 71 | paymentProfileId: $data['payment_profile_id'], 72 | posId: $data['pos_id'], 73 | format: $data['format'], 74 | size: $data['size'], 75 | color: $data['color'], 76 | ), 77 | ); 78 | } 79 | 80 | public static function getGenerateStaticQRCodeLinkData(): array 81 | { 82 | //phpcs:disable 83 | $paymentProfileId = 'abc123'; 84 | $posId = 'POS0001'; 85 | 86 | return [ 87 | 'Static - Default' => [ 88 | 'data' => [ 89 | 'payment_profile_id' => $paymentProfileId, 90 | 'pos_id' => $posId, 91 | 'format' => QrImageFormat::PNG, 92 | 'size' => QrImageSize::SMALL, 93 | 'color' => QrImageColor::MAGENTA, 94 | ], 95 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fl%2F1%2Fabc123%2FPOS0001&f=PNG&s=S&cl=magenta', 96 | ], 97 | 'Static - SVG Medium Black' => [ 98 | 'data' => [ 99 | 'payment_profile_id' => $paymentProfileId, 100 | 'pos_id' => $posId, 101 | 'format' => QrImageFormat::SVG, 102 | 'size' => QrImageSize::MEDIUM, 103 | 'color' => QrImageColor::BLACK, 104 | ], 105 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fl%2F1%2Fabc123%2FPOS0001&f=SVG&s=M&cl=black', 106 | ], 107 | 'Static - PNG Large Magenta' => [ 108 | 'data' => [ 109 | 'payment_profile_id' => $paymentProfileId, 110 | 'pos_id' => $posId, 111 | 'format' => QrImageFormat::PNG, 112 | 'size' => QrImageSize::LARGE, 113 | 'color' => QrImageColor::MAGENTA, 114 | ], 115 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Fl%2F1%2Fabc123%2FPOS0001&f=PNG&s=L&cl=magenta', 116 | ], 117 | ]; 118 | //phpcs:enable 119 | } 120 | 121 | #[DataProvider('getGenerateQRCodeWithMetadataData')] 122 | public function testGenerateQRCodeWithMetadata(array $data, $expected) 123 | { 124 | if ($expected instanceof \Exception) { 125 | $this->expectException(\InvalidArgumentException::class); 126 | $this->expectExceptionMessage($expected->getMessage()); 127 | } 128 | 129 | $result = PayconiqQrCodeGenerator::generateQRCodeWithMetadata( 130 | paymentProfileId: $data['payment_profile_id'], 131 | description: $data['description'], 132 | amount: $data['amount'], 133 | reference: $data['reference'], 134 | format: $data['format'], 135 | size: $data['size'], 136 | color: $data['color'], 137 | ); 138 | 139 | $this->assertEquals($expected, $result); 140 | } 141 | 142 | public static function getGenerateQRCodeWithMetadataData(): array 143 | { 144 | //phpcs:disable 145 | $paymentProfileId = 'abc123'; 146 | 147 | return [ 148 | 'Metadata - Default with Amount and Reference' => [ 149 | 'data' => [ 150 | 'payment_profile_id' => $paymentProfileId, 151 | 'description' => null, 152 | 'amount' => 1000, 153 | 'reference' => '#123.abc!@', 154 | 'format' => QrImageFormat::PNG, 155 | 'size' => QrImageSize::SMALL, 156 | 'color' => QrImageColor::MAGENTA, 157 | ], 158 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Ft%2F1%2Fabc123%3FA%3D1000%26R%3D%2523123.abc%2521%2540&f=PNG&s=S&cl=magenta', 159 | ], 160 | 'Metadata - SVG Medium Black with Description, Amount and Reference' => [ 161 | 'data' => [ 162 | 'payment_profile_id' => $paymentProfileId, 163 | 'description' => 'please pay me', 164 | 'amount' => 1000, 165 | 'reference' => '#123.abc!@--%123--', 166 | 'format' => QrImageFormat::SVG, 167 | 'size' => QrImageSize::MEDIUM, 168 | 'color' => QrImageColor::BLACK, 169 | ], 170 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Ft%2F1%2Fabc123%3FD%3Dplease%2520pay%2520me%26A%3D1000%26R%3D%2523123.abc%2521%2540--%2525123--&f=SVG&s=M&cl=black', 171 | ], 172 | 'Metadata - PNG Large Magenta with Amount' => [ 173 | 'data' => [ 174 | 'payment_profile_id' => $paymentProfileId, 175 | 'description' => null, 176 | 'amount' => 9900, 177 | 'reference' => null, 178 | 'format' => QrImageFormat::PNG, 179 | 'size' => QrImageSize::LARGE, 180 | 'color' => QrImageColor::MAGENTA, 181 | ], 182 | 'expected' => 'https://qrcodegenerator.api.bancontact.net/qrcode?c=https%3A%2F%2Fpayconiq.com%2Ft%2F1%2Fabc123%3FA%3D9900&f=PNG&s=L&cl=magenta', 183 | ], 184 | 'Metadata - Expect Exception for too long description' => [ 185 | 'data' => [ 186 | 'payment_profile_id' => $paymentProfileId, 187 | 'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', 188 | 'amount' => 9900, 189 | 'reference' => null, 190 | 'format' => QrImageFormat::PNG, 191 | 'size' => QrImageSize::LARGE, 192 | 'color' => QrImageColor::MAGENTA, 193 | ], 194 | 'expected' => new \InvalidArgumentException('Description max length is 35 characters'), 195 | ], 196 | 'Metadata - Expect Exception for too small amount' => [ 197 | 'data' => [ 198 | 'payment_profile_id' => $paymentProfileId, 199 | 'description' => 'xxx', 200 | 'amount' => 0, 201 | 'reference' => null, 202 | 'format' => QrImageFormat::PNG, 203 | 'size' => QrImageSize::LARGE, 204 | 'color' => QrImageColor::MAGENTA, 205 | ], 206 | 'expected' => new \InvalidArgumentException('Amount must be between 1 - 999999 Euro cents'), 207 | ], 208 | 'Metadata - Expect Exception for too big amount' => [ 209 | 'data' => [ 210 | 'payment_profile_id' => $paymentProfileId, 211 | 'description' => 'xxx', 212 | 'amount' => 10000000000, 213 | 'reference' => null, 214 | 'format' => QrImageFormat::PNG, 215 | 'size' => QrImageSize::LARGE, 216 | 'color' => QrImageColor::MAGENTA, 217 | ], 218 | 'expected' => new \InvalidArgumentException('Amount must be between 1 - 999999 Euro cents'), 219 | ], 220 | 'Metadata - Expect Exception for too long reference' => [ 221 | 'data' => [ 222 | 'payment_profile_id' => $paymentProfileId, 223 | 'description' => 'xxx', 224 | 'amount' => 9900, 225 | 'reference' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', 226 | 'format' => QrImageFormat::PNG, 227 | 'size' => QrImageSize::LARGE, 228 | 'color' => QrImageColor::MAGENTA, 229 | ], 230 | 'expected' => new \InvalidArgumentException('Reference max length is 35 characters'), 231 | ], 232 | ]; 233 | //phpcs:enable 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /tests/PayconiqApiClientTest.php: -------------------------------------------------------------------------------- 1 | apiKey = 'some-api-key'; 31 | $this->httpClient = $this->createMock(Client::class); 32 | $this->useProd = false; 33 | 34 | $this->payconiqApiClient = new PayconiqApiClient( 35 | apiKey: $this->apiKey, 36 | httpClient: $this->httpClient, 37 | useProd: $this->useProd, 38 | ); 39 | } 40 | 41 | public function testSetApiKey(): void 42 | { 43 | $this->payconiqApiClient->setApiKey('new-api-key'); 44 | $this->assertEquals('new-api-key', $this->payconiqApiClient->getApiKey()); 45 | } 46 | 47 | public function testRequestPayment(): void 48 | { 49 | $requestPayment = RequestPayment::createForStaticQR(10, 'pos-id'); 50 | 51 | $this->httpClient 52 | ->expects($this->once()) 53 | ->method('post') 54 | ->with( 55 | 'https://merchant.api.preprod.bancontact.net/v3/payments/pos', 56 | [ 57 | RequestOptions::HEADERS => [ 58 | 'Authorization' => 'Bearer ' . $this->apiKey, 59 | ], 60 | RequestOptions::JSON => $requestPayment->toArray(), 61 | ], 62 | ) 63 | ->willReturnCallback(function ($uri, array $options) { 64 | $this->assertEquals('https://merchant.api.preprod.bancontact.net/v3/payments/pos', $uri); 65 | $this->assertMatchesJsonSnapshot($options); 66 | 67 | return new Response(200, [], json_encode([ 68 | 'paymentId' => 'payment-id', 69 | 'createdAt' => '2022-01-25', 70 | 'status' => 'PENDING', 71 | 'amount' => 10, 72 | ])); 73 | }); 74 | 75 | $this->payconiqApiClient->requestPayment($requestPayment); 76 | } 77 | 78 | public function testRequestPaymentItShouldThrow(): void 79 | { 80 | $requestPayment = RequestPayment::createForStaticQR(10, 'pos-id'); 81 | 82 | $this->httpClient 83 | ->expects($this->once()) 84 | ->method('post') 85 | ->with( 86 | 'https://merchant.api.preprod.bancontact.net/v3/payments/pos', 87 | [ 88 | RequestOptions::HEADERS => [ 89 | 'Authorization' => 'Bearer ' . $this->apiKey, 90 | ], 91 | RequestOptions::JSON => $requestPayment->toArray(), 92 | ], 93 | ) 94 | ->willThrowException( 95 | new ClientException( 96 | 'some-message', 97 | $this->createMock(Request::class), 98 | $this->createMock(Response::class), 99 | ), 100 | ); 101 | 102 | $this->expectException(PayconiqApiException::class); 103 | $this->expectExceptionMessage('some-message'); 104 | $this->payconiqApiClient->requestPayment($requestPayment); 105 | } 106 | 107 | public function testGetPayment(): void 108 | { 109 | $paymentId = 'payment-id'; 110 | $this->httpClient 111 | ->expects($this->once()) 112 | ->method('get') 113 | ->with( 114 | 'https://merchant.api.preprod.bancontact.net/v3/payments/' . $paymentId, 115 | [ 116 | RequestOptions::HEADERS => [ 117 | 'Authorization' => 'Bearer ' . $this->apiKey, 118 | ], 119 | ], 120 | ) 121 | ->willReturnCallback(function ($uri, array $options) { 122 | $this->assertEquals('https://merchant.api.preprod.bancontact.net/v3/payments/payment-id', $uri); 123 | $this->assertMatchesJsonSnapshot($options); 124 | 125 | return new Response(200, [], json_encode([ 126 | 'paymentId' => 'payment-id', 127 | 'createdAt' => '2022-01-25', 128 | 'status' => 'PENDING', 129 | 'amount' => 10, 130 | ])); 131 | }); 132 | 133 | $this->payconiqApiClient->getPayment($paymentId); 134 | } 135 | 136 | public function testGetPaymentItShouldThrow(): void 137 | { 138 | $paymentId = 'payment-id'; 139 | $this->httpClient 140 | ->expects($this->once()) 141 | ->method('get') 142 | ->with( 143 | 'https://merchant.api.preprod.bancontact.net/v3/payments/' . $paymentId, 144 | [ 145 | RequestOptions::HEADERS => [ 146 | 'Authorization' => 'Bearer ' . $this->apiKey, 147 | ], 148 | ], 149 | ) 150 | ->willThrowException( 151 | new ClientException( 152 | 'some-message', 153 | $this->createMock(Request::class), 154 | $this->createMock(Response::class), 155 | ), 156 | ); 157 | 158 | $this->expectException(PayconiqApiException::class); 159 | $this->expectExceptionMessage('some-message'); 160 | $this->payconiqApiClient->getPayment($paymentId); 161 | } 162 | 163 | public function testCancelPayment(): void 164 | { 165 | $paymentId = 'payment-id'; 166 | $this->httpClient 167 | ->expects($this->once()) 168 | ->method('delete') 169 | ->with( 170 | 'https://merchant.api.preprod.bancontact.net/v3/payments/' . $paymentId, 171 | [ 172 | RequestOptions::HEADERS => [ 173 | 'Authorization' => 'Bearer ' . $this->apiKey, 174 | ], 175 | ], 176 | ); 177 | 178 | $this->payconiqApiClient->cancelPayment($paymentId); 179 | } 180 | 181 | public function testCancelPaymentItShouldThrow(): void 182 | { 183 | $paymentId = 'payment-id'; 184 | $this->httpClient 185 | ->expects($this->once()) 186 | ->method('delete') 187 | ->with( 188 | 'https://merchant.api.preprod.bancontact.net/v3/payments/' . $paymentId, 189 | [ 190 | RequestOptions::HEADERS => [ 191 | 'Authorization' => 'Bearer ' . $this->apiKey, 192 | ], 193 | ], 194 | ) 195 | ->willThrowException( 196 | new ClientException( 197 | 'some-message', 198 | $this->createMock(Request::class), 199 | $this->createMock(Response::class), 200 | ), 201 | ); 202 | 203 | $this->expectException(PayconiqApiException::class); 204 | $this->expectExceptionMessage('some-message'); 205 | 206 | $this->payconiqApiClient->cancelPayment($paymentId); 207 | } 208 | 209 | public function testSearchPayments(): void 210 | { 211 | $searchPayments = new SearchPayments(new \DateTime('2022-01-25')); 212 | 213 | $this->httpClient 214 | ->expects($this->once()) 215 | ->method('post') 216 | ->with( 217 | 'https://merchant.api.preprod.bancontact.net/v3/payments/search?page=0&size=100', 218 | [ 219 | RequestOptions::HEADERS => [ 220 | 'Authorization' => 'Bearer ' . $this->apiKey, 221 | ], 222 | RequestOptions::JSON => $searchPayments->toArray(), 223 | ], 224 | ) 225 | ->willReturnCallback(function ($uri, array $options) { 226 | $this->assertEquals( 227 | 'https://merchant.api.preprod.bancontact.net/v3/payments/search?page=0&size=100', 228 | $uri, 229 | ); 230 | $this->assertMatchesJsonSnapshot($options); 231 | 232 | return new Response(200, [], json_encode([ 233 | 'size' => 100, 234 | 'totalPages' => 20, 235 | 'totalElements' => 200, 236 | 'number' => 1, 237 | ])); 238 | }); 239 | 240 | $this->payconiqApiClient->searchPayments($searchPayments, 0, 100); 241 | } 242 | 243 | public function testSearchPaymentsItShouldThrow(): void 244 | { 245 | $searchPayments = new SearchPayments(new \DateTime('2022-01-25')); 246 | 247 | $this->httpClient 248 | ->expects($this->once()) 249 | ->method('post') 250 | ->with( 251 | 'https://merchant.api.preprod.bancontact.net/v3/payments/search?page=0&size=100', 252 | [ 253 | RequestOptions::HEADERS => [ 254 | 'Authorization' => 'Bearer ' . $this->apiKey, 255 | ], 256 | RequestOptions::JSON => $searchPayments->toArray(), 257 | ], 258 | ) 259 | ->willThrowException( 260 | new ClientException( 261 | 'some-message', 262 | $this->createMock(Request::class), 263 | $this->createMock(Response::class), 264 | ), 265 | ); 266 | 267 | $this->expectException(PayconiqApiException::class); 268 | $this->expectExceptionMessage('some-message'); 269 | 270 | $this->payconiqApiClient->searchPayments($searchPayments, 0, 100); 271 | } 272 | 273 | public function testRefundPayment(): void 274 | { 275 | $paymentId = 'payment-id'; 276 | $this->httpClient 277 | ->expects($this->once()) 278 | ->method('get') 279 | ->with('https://merchant.api.preprod.bancontact.net/v3/payments/' . $paymentId . '/debtor/refundIban'); 280 | 281 | $this->payconiqApiClient->refundPayment($paymentId); 282 | } 283 | 284 | public function testRefundPaymentItShouldThrow(): void 285 | { 286 | $paymentId = 'payment-id'; 287 | $this->httpClient 288 | ->expects($this->once()) 289 | ->method('get') 290 | ->with( 291 | 'https://merchant.api.preprod.bancontact.net/v3/payments/' . $paymentId . '/debtor/refundIban', 292 | ) 293 | ->willThrowException( 294 | new ClientException( 295 | 'some-message', 296 | $this->createMock(Request::class), 297 | $this->createMock(Response::class), 298 | ), 299 | ); 300 | 301 | $this->expectException(PayconiqApiException::class); 302 | $this->expectExceptionMessage('some-message'); 303 | 304 | $this->payconiqApiClient->refundPayment($paymentId); 305 | } 306 | } 307 | --------------------------------------------------------------------------------