├── 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 | [](https://github.com/optiosteam/payconiq-client-php/actions/workflows/tests.yaml)
2 | [](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 |
--------------------------------------------------------------------------------