diff --git a/AGENTS.md b/AGENTS.md index e5ab7e9..c2163a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,8 @@ This repository is a Laravel package. Keep the package focused, idiomatic, and e - The package follows the official SimplePay PHP SDKs (API v2, published at https://simplepay.hu/fejlesztoknek/). `official-docs/` holds the reference PDFs; it is not part of the dist archive. - `src/SimplePay.php` sits behind the `SimplePay` facade and exposes the services in `src/Services`: `PaymentService` (start, finish, refund, query, cancel, qvik/EAM, Apple Pay, merchant-side Google Pay with `doGooglePay()` and `googlePayConfig()`), `CardStorageService` (OneClick `do`, recurring, card and token query/cancel), `AutoPaymentService` (merchant-side card data) and `RtpService` (Request to Pay). -- Request and response payloads are readonly DTOs in `src/Dto`: `toArray()` builds the API body including `salt` and `sdkVersion`, `fromArray()` maps the response. Enums live in `src/Enums`; `ErrorCode` mirrors the official error code list with Hungarian descriptions. -- `src/Support/Client.php` signs every request with HMAC-SHA384 (`Support/Signature`), verifies the response signature and throws `SimplePayApiException` when `errorCodes` is not empty; the exception keeps the response body (`getResponseData()`), e.g. the `redirectUrl` of a 3DS challenge (3004). `doGooglePay()` turns a 3004 with a `redirectUrl` into a normal `GooglePayResponse`. +- Request and response payloads are readonly DTOs in `src/Dto`: `toArray()` builds the API body with the field names of the official SDK samples (check `tests/Unit/RequestPayloadTest.php` when adding fields), `fromArray()` maps the response. Deprecated constructor parameters stay until the next major version but are not sent. Enums live in `src/Enums`; `ErrorCode` mirrors the official error code list with Hungarian descriptions. +- `src/Support/Client.php` adds `sdkVersion` (`Client::SDK_VERSION`) to every request that lacks it, as the official SDK does, signs every request with HMAC-SHA384 (`Support/Signature`), masks card data and tokens in the debug log, verifies the response signature and throws `SimplePayApiException` when `errorCodes` is not empty; the exception keeps the response body (`getResponseData()`), e.g. the `redirectUrl` of a 3DS challenge (3004). `doGooglePay()` turns a 3004 with a `redirectUrl` into a normal `GooglePayResponse`. - Merchant credentials are configured per currency (`simplepay.merchants`) and resolved by `Support/MerchantResolver`; IPN bodies without `currency` are resolved by their `merchant` field. - Routes: `POST simplepay/ipn` (`VerifySimplePaySignature` middleware; `SimplePayWebhookController` dispatches the events in `src/Events`) and `GET simplepay/back` (`SimplePayBackController`, response customisable through the `BackUrlResponse` contract). - Consumer documentation is bilingual: keep `examples/en/` and `examples/hu/` in sync with each other and with the README. diff --git a/README.md b/README.md index 02a3013..243e851 100644 --- a/README.md +++ b/README.md @@ -232,10 +232,15 @@ use Netipar\SimplePay\Dto\CardStorageDoRequest; // Pay with a stored card $result = SimplePay::cardStorage()->do(new CardStorageDoRequest( currency: Currency::HUF, - total: 5000, + transactionId: null, // deprecated: not sent, removed in the next major version + cardId: $savedCardId, orderRef: 'ORDER-2026-002', + total: 5000, customerEmail: 'customer@example.com', - cardId: $savedCardId, + url: route('simplepay.back'), + cardSecret: $cardSecret, + threeDSReqAuthMethod: '02', + browser: $browserData, // the customer's browser data for the 3DS check )); // Query stored card @@ -247,14 +252,20 @@ SimplePay::cardStorage()->cardCancel(Currency::HUF, $cardId); ### Recurring Payments +The start of a card registration (`recurring` in `PaymentRequest`) returns the recurring tokens in `$response->tokens`; they become active when the IPN reports the successful payment. + ```php use Netipar\SimplePay\Dto\RecurringRequest; +use Netipar\SimplePay\Enums\TransactionType; $result = SimplePay::cardStorage()->doRecurring(new RecurringRequest( currency: Currency::HUF, - total: 9900, + transactionId: null, // deprecated: not sent, removed in the next major version orderRef: 'SUB-2026-003', + total: 9900, customerEmail: 'customer@example.com', + url: route('simplepay.back'), + type: TransactionType::Mit, token: $recurringToken, )); ``` @@ -265,19 +276,23 @@ $result = SimplePay::cardStorage()->doRecurring(new RecurringRequest( use Netipar\SimplePay\Dto\ApplePayStartRequest; use Netipar\SimplePay\Dto\ApplePayDoRequest; -// Start Apple Pay session +// Start Apple Pay session (returns the API response with applePaySession). +// Pass `domain` when the checkout does not run on the host of APP_URL. $session = SimplePay::payment()->startApplePay(new ApplePayStartRequest( currency: Currency::HUF, total: 5000, orderRef: 'ORDER-AP-001', customerEmail: 'customer@example.com', + url: route('simplepay.back'), + domain: 'shop.example.com', // the FQDN registered for Apple Pay; defaults to the host of app.url )); -// Complete Apple Pay payment +// Complete Apple Pay payment with the token from Apple Pay JS $result = SimplePay::payment()->doApplePay(new ApplePayDoRequest( currency: Currency::HUF, - transactionId: $session->transactionId, - applePayToken: $tokenFromJs, + transactionId: $session['transactionId'], + orderRef: 'ORDER-AP-001', + token: $tokenFromJs, )); ``` @@ -310,6 +325,30 @@ if ($response->requiresChallenge()) { Set `SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID` and `SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME` from the Google Pay & Wallet Console. The full flow with the JavaScript button is in the [Google Pay example](examples/en/google-pay.md). +### Auto Payment (Card Data on Your Page) + +PCI-DSS certified merchants can send the card data themselves: + +```php +use Netipar\SimplePay\Dto\AutoPaymentRequest; +use Netipar\SimplePay\Dto\CardData; +use Netipar\SimplePay\Enums\TransactionType; + +$result = SimplePay::autoPayment()->auto(new AutoPaymentRequest( + currency: Currency::HUF, + total: 2500, + orderRef: 'ORDER-AUTO-001', + customerEmail: 'customer@example.com', + cardData: new CardData(cardNumber: $number, expiry: '0130', cvc: $cvc, cardHolder: 'John Doe'), + type: TransactionType::Cit, + browserData: $browserData, // the customer's browser data for the 3DS check + url: route('simplepay.back'), // where SimplePay returns the customer after a 3DS challenge + threeDSReqAuthMethod: '02', +)); +``` + +See [3DS browser data](examples/en/card-storage.md#3ds-browser-data) for building `BrowserData`. + ### EAM / Qvik Payment ```php @@ -321,6 +360,7 @@ $response = SimplePay::payment()->startEam(new EamStartRequest( orderRef: 'ORDER-EAM-001', customerEmail: 'customer@example.com', url: route('simplepay.back'), + eamType: 1, // 1 QR, 2 deeplink, 3 NFC, 4-7 combinations )); ``` @@ -342,8 +382,8 @@ $result = SimplePay::rtp()->start(new RtpStartRequest( // Query RTP status $status = SimplePay::rtp()->query(Currency::HUF, transactionId: $id); -// Refund RTP -SimplePay::rtp()->refund(Currency::HUF, $transactionId, 15000); +// Refund RTP (refund total, then the original total) +SimplePay::rtp()->refund(Currency::HUF, $transactionId, 15000, originalTotal: 15000); // Reverse RTP SimplePay::rtp()->reverse(Currency::HUF, $transactionId); diff --git a/examples/en/card-storage.md b/examples/en/card-storage.md index 0697a18..d71848f 100644 --- a/examples/en/card-storage.md +++ b/examples/en/card-storage.md @@ -17,15 +17,40 @@ use Netipar\SimplePay\Dto\CardStorageDoRequest; $result = SimplePay::cardStorage()->do(new CardStorageDoRequest( currency: Currency::HUF, - transactionId: 123456, + transactionId: null, // deprecated: not sent, removed in the next major version cardId: 'card-abc-123', orderRef: 'ORDER-456', total: 3000, customerEmail: 'customer@example.com', - url: route('simplepay.back'), + url: route('simplepay.back'), // where SimplePay returns the customer after a 3DS challenge + cardSecret: $cardSecret, // the secret the customer gave when the card was stored + threeDSReqAuthMethod: '02', // 01 guest, 02 registered, 05 third-party login + browser: $browser, // see "3DS Browser Data" below )); ``` +### 3DS Browser Data + +OneClick and auto payments send the customer's browser data to the card issuer's 3DS check. Collect `navigator.language`, `screen.colorDepth`, `screen.height`, `screen.width` and `new Date().getTimezoneOffset()` on the checkout page, and add the request headers and IP address on the server: + +```php +use Netipar\SimplePay\Dto\BrowserData; + +$browser = new BrowserData( + accept: (string) $request->header('Accept'), + agent: (string) $request->userAgent(), + language: $request->input('browser.lang'), + javaEnabled: false, + colorDepth: (int) $request->input('browser.color'), + screenHeight: (int) $request->input('browser.height'), + screenWidth: (int) $request->input('browser.width'), + timeZone: (int) $request->input('browser.tz'), + ip: $request->ip(), +); +``` + +Without browser data the issuer's 3DS check may fail. If the issuer asks for a challenge, the API answers with error code `3004`; send the customer to `$e->getResponseData()['redirectUrl']`. + ## Recurring Payment (Merchant-Initiated) Charge a stored card for a merchant-initiated recurring transaction (e.g., subscriptions, scheduled billing). @@ -38,15 +63,18 @@ use Netipar\SimplePay\Dto\RecurringRequest; $result = SimplePay::cardStorage()->doRecurring(new RecurringRequest( currency: Currency::HUF, - transactionId: 789012, + transactionId: null, // deprecated: not sent, removed in the next major version orderRef: 'ORDER-REC-001', total: 5000, customerEmail: 'customer@example.com', url: route('simplepay.back'), type: TransactionType::Mit, + token: $recurringToken, // required: one of the tokens of the card registration )); ``` +A recurring payment without `token` throws an `InvalidArgumentException` before the request is sent. + ### Transaction Types | Type | Description | @@ -127,18 +155,19 @@ $response = SimplePay::payment()->start(new PaymentRequest( items: [new Item(title: 'Monthly plan', price: 5000, quantity: 1)], )); -// 2. Customer completes payment, card is stored... -// 3. IPN webhook receives cardId in the response... +// 2. Store the recurring tokens of the registration; they become active when the IPN reports the successful payment +$recurringToken = $response->tokens[0]; // 4. Monthly recurring charge (merchant-initiated, no customer interaction) $result = SimplePay::cardStorage()->doRecurring(new RecurringRequest( currency: Currency::HUF, - transactionId: $originalTransactionId, + transactionId: null, orderRef: 'SUB-MONTH-02', total: 5000, customerEmail: 'customer@example.com', url: route('simplepay.back'), type: TransactionType::Mit, + token: $recurringToken, )); // 5. When subscription is cancelled, remove the stored card diff --git a/examples/en/rtp.md b/examples/en/rtp.md index 5fa3d8f..2d290c8 100644 --- a/examples/en/rtp.md +++ b/examples/en/rtp.md @@ -4,7 +4,7 @@ Request to Pay (RTP) is a payment initiation method where the merchant sends a p ## Start an RTP Payment -Initiate a single Request to Pay transaction. +Initiate a single Request to Pay transaction. The request expires at `deadline` (ISO 8601); without it, 2 hours 5 minutes after the start, as in the official SDK sample, or `timeout` seconds later if you pass `timeout`. ```php use Netipar\SimplePay\Facades\SimplePay; @@ -53,7 +53,7 @@ On the package's `simplepay.back` route the pending redirect arrives as `BackEve ## Batch RTP -Send multiple payment requests at once using the batch RTP method. +Send multiple payment requests at once using the batch RTP method. The batch is sent as a package: pass `packageRef` to identify it in your system (it is generated otherwise), and `deadline` per payment (30 days from now by default). ```php use Netipar\SimplePay\Facades\SimplePay; @@ -95,6 +95,10 @@ use Netipar\SimplePay\Facades\SimplePay; use Netipar\SimplePay\Enums\Currency; $query = SimplePay::rtp()->query(Currency::HUF, transactionId: '12345'); + +// By your order reference, or by the package reference of a batch +$query = SimplePay::rtp()->query(Currency::HUF, orderRef: 'RTP-ORDER-001'); +$query = SimplePay::rtp()->query(Currency::HUF, packageRef: 'PKG-2026-10'); ``` ## Refund an RTP Transaction @@ -105,11 +109,11 @@ Issue a refund for a completed RTP payment. use Netipar\SimplePay\Facades\SimplePay; use Netipar\SimplePay\Enums\Currency; -// Partial refund -SimplePay::rtp()->refund(Currency::HUF, '12345', 1000); +// Partial refund: the refund total, then the original total of the transaction +SimplePay::rtp()->refund(Currency::HUF, '12345', 1000, originalTotal: 5000); // Full refund -SimplePay::rtp()->refund(Currency::HUF, '12345', 5000); +SimplePay::rtp()->refund(Currency::HUF, '12345', 5000, originalTotal: 5000); ``` ## Reverse an RTP Transaction diff --git a/examples/hu/kartya-tarolas.md b/examples/hu/kartya-tarolas.md index c04da72..edc7207 100644 --- a/examples/hu/kartya-tarolas.md +++ b/examples/hu/kartya-tarolas.md @@ -13,15 +13,40 @@ use Netipar\SimplePay\Dto\CardStorageDoRequest; $result = SimplePay::cardStorage()->do(new CardStorageDoRequest( currency: Currency::HUF, - transactionId: 123456, + transactionId: null, // deprecated: not sent, removed in the next major version cardId: 'card-abc-123', orderRef: 'ORDER-456', total: 3000, customerEmail: 'vasarlo@example.com', - url: route('simplepay.back'), + url: route('simplepay.back'), // where SimplePay returns the customer after a 3DS challenge + cardSecret: $cardSecret, // the secret the customer gave when the card was stored + threeDSReqAuthMethod: '02', // 01 guest, 02 registered, 05 third-party login + browser: $browser, // see the "Böngészőadatok a 3DS-hez" section below )); ``` +### Böngészőadatok a 3DS-hez + +A OneClick és az AutoPayment fizetés a vásárló böngészőadatait is elküldi a kártyakibocsátó 3DS-ellenőrzéséhez. A fizetési oldalon gyűjtsd be a `navigator.language`, a `screen.colorDepth`, a `screen.height`, a `screen.width` és a `new Date().getTimezoneOffset()` értékét, a szerveren pedig add hozzá a kérés fejléceit és az IP-címet: + +```php +use Netipar\SimplePay\Dto\BrowserData; + +$browser = new BrowserData( + accept: (string) $request->header('Accept'), + agent: (string) $request->userAgent(), + language: $request->input('browser.lang'), + javaEnabled: false, + colorDepth: (int) $request->input('browser.color'), + screenHeight: (int) $request->input('browser.height'), + screenWidth: (int) $request->input('browser.width'), + timeZone: (int) $request->input('browser.tz'), + ip: $request->ip(), +); +``` + +Böngészőadatok nélkül a 3DS-ellenőrzés hibával zárulhat. Ha a kibocsátó challenge-et kér, az API `3004`-es hibakóddal válaszol; ilyenkor a vásárlót a `$e->getResponseData()['redirectUrl']` címre kell irányítani. + ## Ismétlődő fizetés (recurring) Az ismétlődő fizetés (MIT - Merchant Initiated Transaction) lehetővé teszi, hogy a kereskedői oldalról indítsunk fizetést a vásárló közvetlen közreműködése nélkül: @@ -32,15 +57,18 @@ use Netipar\SimplePay\Enums\TransactionType; $result = SimplePay::cardStorage()->doRecurring(new RecurringRequest( currency: Currency::HUF, - transactionId: 789012, + transactionId: null, // deprecated: not sent, removed in the next major version orderRef: 'ORDER-REC-001', total: 5000, customerEmail: 'vasarlo@example.com', url: route('simplepay.back'), type: TransactionType::Mit, + token: $recurringToken, // required: one of the tokens of the card registration )); ``` +A `token` nélküli ismétlődő fizetés még a kérés elküldése előtt `InvalidArgumentException`-t dob. + ### Tranzakció típusok | Típus | Leírás | @@ -93,7 +121,7 @@ SimplePay::cardStorage()->tokenCancel(Currency::HUF, 'token-xyz'); A kártyatárolással történő fizetés teljes folyamata a következő: 1. **Első fizetés** - A vásárló fizet és hozzájárul a kártya tárolásához (`recurring` paraméter megadásával a `start()` hívásban). -2. **Kártya tárolása** - A SimplePay tárolja a kártyát, és visszaküldi a `cardId`-t és/vagy `token`-t az IPN-ben. +2. **Kártya tárolása** - A `start()` válasza a regisztráció ismétlődő fizetéshez használható tokenjeit is visszaadja (`$response->tokens`); ezek a sikeres fizetést jelző IPN-nel aktiválódnak. 3. **Következő fizetések** - A tárolt `cardId`-vel vagy `token`-nel indíthatod a következő fizetéseket (`do()` vagy `doRecurring()`). 4. **Kártya törlés** - Amikor a vásárló kérelmezi, törölheted a tárolt kártyát (`cardCancel()`). diff --git a/examples/hu/rtp.md b/examples/hu/rtp.md index 33f3866..0ce197c 100644 --- a/examples/hu/rtp.md +++ b/examples/hu/rtp.md @@ -4,7 +4,7 @@ A `SimplePay::rtp()` metódusain keresztül kezelheted a Request to Pay (azonnal ## RTP indítás -Egyedi fizetési kérés indítása: +Egyedi fizetési kérés indítása. A kérés a `deadline` időpontban (ISO 8601) jár le; ha nincs megadva, az indítás után 2 óra 5 perccel, ahogy a hivatalos SDK mintája, vagy `timeout` megadása esetén annyi másodperccel később. ```php use Netipar\SimplePay\Facades\SimplePay; @@ -54,7 +54,7 @@ A csomag `simplepay.back` route-ján a pending visszairányítás `BackEvent::Pe ## Kötegelt RTP -Több fizetési kérés egyidejűleg történő küldése: +Több fizetési kérés egyidejűleg történő küldése. A köteg csomagként megy: a `packageRef` azonosítja a rendszeredben (ha nem adod meg, a csomag generál egyet), a `deadline` pedig fizetési kérésenként adható meg (alapértéke 30 nap). ```php use Netipar\SimplePay\Dto\RtpDoRequest; @@ -91,6 +91,10 @@ Egy RTP tranzakció állapotának lekérdezése: ```php $query = SimplePay::rtp()->query(Currency::HUF, transactionId: '12345'); + +// By your order reference, or by the package reference of a batch +$query = SimplePay::rtp()->query(Currency::HUF, orderRef: 'RTP-ORDER-001'); +$query = SimplePay::rtp()->query(Currency::HUF, packageRef: 'PKG-2026-10'); ``` ## RTP visszatérítés (refund) @@ -98,10 +102,10 @@ $query = SimplePay::rtp()->query(Currency::HUF, transactionId: '12345'); RTP tranzakció visszatérítése: ```php -SimplePay::rtp()->refund(Currency::HUF, '12345', 1000); +SimplePay::rtp()->refund(Currency::HUF, '12345', 1000, originalTotal: 5000); ``` -A harmadik paraméter a visszatérítendő összeg. Részleges visszatérítés is lehetséges. +A harmadik paraméter a visszatérítendő összeg, az `originalTotal` az eredeti tranzakció összege. Részleges visszatérítés is lehetséges. ## RTP sztornó (reverse) diff --git a/resources/boost/skills/simplepay-development/SKILL.md b/resources/boost/skills/simplepay-development/SKILL.md index 0a217ff..b091d85 100644 --- a/resources/boost/skills/simplepay-development/SKILL.md +++ b/resources/boost/skills/simplepay-development/SKILL.md @@ -236,11 +236,15 @@ use Netipar\SimplePay\Dto\CardStorageDoRequest; $request = new CardStorageDoRequest( currency: Currency::HUF, - customerEmail: 'customer@example.com', - orderRef: 'ORDER-003', - items: [new Item(title: 'Product', price: 1000, quantity: 1)], - url: route('payment.back'), + transactionId: null, // deprecated: not sent cardId: 'stored-card-id', + orderRef: 'ORDER-003', + total: 1000, + customerEmail: 'customer@example.com', + url: route('simplepay.back'), // return URL after a 3DS challenge + cardSecret: $cardSecret, + threeDSReqAuthMethod: '02', // 01 guest, 02 registered, 05 third-party login + browser: $browserData, // BrowserData: accept, agent, language, colorDepth, screen size, timeZone, ip ); $result = SimplePay::cardStorage()->do($request); @@ -250,14 +254,19 @@ $result = SimplePay::cardStorage()->do($request); ```php use Netipar\SimplePay\Dto\RecurringRequest; +use Netipar\SimplePay\Enums\TransactionType; + +// The token comes from the card registration start: $response->tokens (active after the successful payment) $request = new RecurringRequest( currency: Currency::HUF, - customerEmail: 'customer@example.com', + transactionId: null, // deprecated: not sent orderRef: 'ORDER-004', - items: [new Item(title: 'Subscription', price: 2990, quantity: 1)], - url: route('payment.back'), - token: 'recurring-token', + total: 2990, + customerEmail: 'customer@example.com', + url: route('simplepay.back'), + type: TransactionType::Mit, + token: 'recurring-token', // required, otherwise InvalidArgumentException ); $result = SimplePay::cardStorage()->doRecurring($request); @@ -319,7 +328,7 @@ $result = SimplePay::rtp()->do(new RtpDoRequest( // Query/refund/reverse RTP SimplePay::rtp()->query(Currency::HUF, transactionId: '12345'); -SimplePay::rtp()->refund(Currency::HUF, transactionId: '12345', amount: 5000); +SimplePay::rtp()->refund(Currency::HUF, transactionId: '12345', amount: 5000, originalTotal: 5000); SimplePay::rtp()->reverse(Currency::HUF, transactionId: '12345'); ``` @@ -472,7 +481,7 @@ All DTOs use readonly properties. Request DTOs include a `toArray()` method that | DTO | Purpose | |---|---| | `PaymentRequest` | Start a payment with items, addresses, methods | -| `PaymentResponse` | Transaction ID, payment URL, timeout (null for RTP starts) | +| `PaymentResponse` | Transaction ID, payment URL, timeout (null for RTP starts), recurring `tokens` of a card registration | | `RedirectUrls` | Per-event redirect URLs (`urls`) for payment and RTP starts | | `GooglePayDoRequest` / `GooglePayResponse` | Merchant-side Google Pay charge; `redirectUrl` when a 3DS challenge is required | | `FinishRequest` / `FinishResponse` | Two-step capture | @@ -488,4 +497,4 @@ All DTOs use readonly properties. Request DTOs include a `toArray()` method that | `RtpStartRequest` / `RtpDoRequest` | Request to Pay flows | | `RtpPayment` | Bank transfer payment details | | `CardData` | Card number, expiry, CVC | -| `BrowserData` | Browser fingerprint for 3DS | +| `BrowserData` | Browser data for the issuer's 3DS check, sent as `browser` (add `ip`) | diff --git a/src/Dto/ApplePayDoRequest.php b/src/Dto/ApplePayDoRequest.php index 6cc8e61..d34839a 100644 --- a/src/Dto/ApplePayDoRequest.php +++ b/src/Dto/ApplePayDoRequest.php @@ -26,7 +26,7 @@ public function toArray(): array 'orderRef' => $this->orderRef, 'currency' => $this->currency->value, 'transactionId' => $this->transactionId, - 'token' => $this->token, + 'applePayToken' => $this->token, ]; } } diff --git a/src/Dto/ApplePayStartRequest.php b/src/Dto/ApplePayStartRequest.php index defaec9..5c48fdf 100644 --- a/src/Dto/ApplePayStartRequest.php +++ b/src/Dto/ApplePayStartRequest.php @@ -4,23 +4,40 @@ namespace Netipar\SimplePay\Dto; +use InvalidArgumentException; use Netipar\SimplePay\Enums\Currency; +use Random\RandomException; readonly class ApplePayStartRequest { + /** + * @param string|null $domain The FQDN registered for Apple Pay at SimplePay; defaults to the host of `app.url`. + */ public function __construct( public Currency $currency, public float $total, public string $orderRef, public string $customerEmail, public string $url, + public ?string $domain = null, + public string $language = 'HU', + public ?int $timeout = null, ) {} /** * @return array + * + * @throws RandomException + * @throws InvalidArgumentException When neither the domain nor the host of `app.url` is known. */ public function toArray(): array { + $domain = $this->domain ?? parse_url((string) config('app.url'), PHP_URL_HOST); + + if (! is_string($domain) || $domain === '') { + throw new InvalidArgumentException('Apple Pay needs the domain registered at SimplePay: pass `domain` to ApplePayStartRequest.'); + } + return [ 'salt' => bin2hex(random_bytes(16)), 'merchant' => '', @@ -28,6 +45,9 @@ public function toArray(): array 'currency' => $this->currency->value, 'total' => $this->total, 'customerEmail' => $this->customerEmail, + 'language' => $this->language, + 'domain' => $domain, + 'timeout' => date('c', time() + ($this->timeout ?? (int) config('simplepay.timeout', 600))), 'url' => $this->url, ]; } diff --git a/src/Dto/AutoPaymentRequest.php b/src/Dto/AutoPaymentRequest.php index 96cd241..a437128 100644 --- a/src/Dto/AutoPaymentRequest.php +++ b/src/Dto/AutoPaymentRequest.php @@ -6,11 +6,15 @@ use Netipar\SimplePay\Enums\Currency; use Netipar\SimplePay\Enums\TransactionType; +use Netipar\SimplePay\Support\Client; readonly class AutoPaymentRequest { /** + * @param BrowserData|null $browserData The customer's browser data, sent as the `browser` object for the issuer's 3DS check. * @param array|null $items + * @param string|null $url Where SimplePay sends the customer back after a 3DS challenge. + * @param string|null $threeDSReqAuthMethod How the customer is registered: 01 guest, 02 registered, 05 third party. */ public function __construct( public Currency $currency, @@ -24,6 +28,9 @@ public function __construct( public ?Address $invoice = null, public ?Address $delivery = null, public ?array $items = null, + public ?string $url = null, + public ?string $threeDSReqAuthMethod = null, + public ?string $language = null, ) {} /** @@ -40,12 +47,15 @@ public function toArray(): array 'customerEmail' => $this->customerEmail, 'cardData' => $this->cardData->toArray(), 'type' => $this->type->value, - 'browserData' => $this->browserData?->toArray(), + 'browser' => $this->browserData?->toArray(), + 'threeDSReqAuthMethod' => $this->threeDSReqAuthMethod, + 'url' => $this->url, + 'language' => $this->language, 'customer' => $this->customer, 'invoice' => $this->invoice?->toArray(), 'delivery' => $this->delivery?->toArray(), 'items' => $this->items ? array_map(fn (Item $item) => $item->toArray(), $this->items) : null, - 'sdkVersion' => 'netipar-simplepay:laravel', + 'sdkVersion' => Client::SDK_VERSION, ], fn (mixed $value): bool => $value !== null); } } diff --git a/src/Dto/BrowserData.php b/src/Dto/BrowserData.php index 2f4533d..c518aec 100644 --- a/src/Dto/BrowserData.php +++ b/src/Dto/BrowserData.php @@ -4,8 +4,16 @@ namespace Netipar\SimplePay\Dto; +/** + * The customer's browser data for the issuer's 3DS check, sent as the `browser` object. + */ readonly class BrowserData { + /** + * @param string $windowSize Deprecated: not part of the SimplePay `browser` object, it is not sent. + * @param string|null $challengePreference Deprecated: not part of the SimplePay `browser` object, it is not sent. + * @param string|null $ip The customer's IP address, e.g. `$request->ip()`. + */ public function __construct( public string $accept, public string $agent, @@ -15,8 +23,9 @@ public function __construct( public int $screenHeight, public int $screenWidth, public int $timeZone, - public string $windowSize, + public string $windowSize = '', public ?string $challengePreference = null, + public ?string $ip = null, ) {} /** @@ -27,14 +36,13 @@ public function toArray(): array return array_filter([ 'accept' => $this->accept, 'agent' => $this->agent, - 'language' => $this->language, - 'javaEnabled' => $this->javaEnabled, - 'colorDepth' => $this->colorDepth, - 'screenHeight' => $this->screenHeight, - 'screenWidth' => $this->screenWidth, - 'timeZone' => $this->timeZone, - 'windowSize' => $this->windowSize, - 'challengePreference' => $this->challengePreference, + 'ip' => $this->ip, + 'java' => $this->javaEnabled, + 'lang' => $this->language, + 'color' => $this->colorDepth, + 'height' => $this->screenHeight, + 'width' => $this->screenWidth, + 'tz' => $this->timeZone, ], fn (mixed $value): bool => $value !== null); } } diff --git a/src/Dto/CardStorageDoRequest.php b/src/Dto/CardStorageDoRequest.php index 3e2934a..dbdca4c 100644 --- a/src/Dto/CardStorageDoRequest.php +++ b/src/Dto/CardStorageDoRequest.php @@ -5,22 +5,36 @@ namespace Netipar\SimplePay\Dto; use Netipar\SimplePay\Enums\Currency; +use Netipar\SimplePay\Enums\TransactionType; use Random\RandomException; +/** + * OneClick payment with a stored card (`do` endpoint). + */ readonly class CardStorageDoRequest { /** + * @param int|null $transactionId Deprecated: the `do` endpoint identifies the card by $cardId; not sent, removed in the next major version. * @param array|null $items + * @param string|null $cardSecret The secret the customer gave when the card was stored. + * @param string|null $threeDSReqAuthMethod How the customer is registered: 01 guest, 02 registered, 05 third party. + * @param BrowserData|null $browser The customer's browser data for the issuer's 3DS check. */ public function __construct( public Currency $currency, - public int $transactionId, + public ?int $transactionId, public string $cardId, public string $orderRef, public float $total, public string $customerEmail, public string $url, public ?array $items = null, + public ?string $cardSecret = null, + public TransactionType $type = TransactionType::Cit, + public ?string $threeDSReqAuthMethod = null, + public ?BrowserData $browser = null, + public ?string $customer = null, + public ?Address $invoice = null, ) {} /** @@ -34,11 +48,17 @@ public function toArray(): array 'salt' => bin2hex(random_bytes(16)), 'merchant' => '', 'orderRef' => $this->orderRef, + 'methods' => ['CARD'], 'currency' => $this->currency->value, - 'transactionId' => $this->transactionId, 'cardId' => $this->cardId, + 'cardSecret' => $this->cardSecret, 'total' => $this->total, 'customerEmail' => $this->customerEmail, + 'customer' => $this->customer, + 'type' => $this->type->value, + 'threeDSReqAuthMethod' => $this->threeDSReqAuthMethod, + 'browser' => $this->browser?->toArray(), + 'invoice' => $this->invoice?->toArray(), 'url' => $this->url, 'items' => $this->items ? array_map(fn (Item $item) => $item->toArray(), $this->items) : null, ], fn (mixed $value): bool => $value !== null); diff --git a/src/Dto/EamStartRequest.php b/src/Dto/EamStartRequest.php index cbca652..003a122 100644 --- a/src/Dto/EamStartRequest.php +++ b/src/Dto/EamStartRequest.php @@ -11,6 +11,7 @@ { /** * @param array|null $items + * @param int $eamType What the generated deeplink is for: 1 QR, 2 deeplink, 3 NFC, 4 QR + deeplink, 5 QR + NFC, 6 deeplink + NFC, 7 all three. */ public function __construct( public Currency $currency, @@ -19,6 +20,9 @@ public function __construct( public string $customerEmail, public string $url, public ?array $items = null, + public int $eamType = 1, + public string $language = 'HU', + public ?int $timeout = null, ) {} /** @@ -35,6 +39,9 @@ public function toArray(): array 'currency' => $this->currency->value, 'total' => $this->total, 'customerEmail' => $this->customerEmail, + 'language' => $this->language, + 'eamType' => $this->eamType, + 'timeout' => date('c', time() + ($this->timeout ?? (int) config('simplepay.timeout', 600))), 'url' => $this->url, 'items' => $this->items ? array_map(fn (Item $item) => $item->toArray(), $this->items) : null, ], fn (mixed $value): bool => $value !== null); diff --git a/src/Dto/Item.php b/src/Dto/Item.php index db73eda..516498b 100644 --- a/src/Dto/Item.php +++ b/src/Dto/Item.php @@ -23,7 +23,7 @@ public function toArray(): array return array_filter([ 'ref' => $this->ref, 'title' => $this->title, - 'description' => $this->description, + 'desc' => $this->description, 'amount' => $this->quantity, 'price' => $this->price, 'tax' => $this->tax, diff --git a/src/Dto/PaymentRequest.php b/src/Dto/PaymentRequest.php index b7b96f9..ce6c397 100644 --- a/src/Dto/PaymentRequest.php +++ b/src/Dto/PaymentRequest.php @@ -14,6 +14,7 @@ * @param array $methods * @param array|null $items * @param array|null $recurring + * @param float|null $shippingPrice Sent as `shippingCost`, which SimplePay adds to the amount to pay. * @param RedirectUrls|null $urls Event-specific redirect URLs; SimplePay ignores $url when they are sent. */ public function __construct( @@ -98,7 +99,7 @@ public function toArray(): array } if ($this->shippingPrice !== null) { - $data['shippingPrice'] = $this->shippingPrice; + $data['shippingCost'] = $this->shippingPrice; } if ($this->discount !== null) { diff --git a/src/Dto/PaymentResponse.php b/src/Dto/PaymentResponse.php index e8b9e04..b6efcab 100644 --- a/src/Dto/PaymentResponse.php +++ b/src/Dto/PaymentResponse.php @@ -8,11 +8,13 @@ { /** * @param string|null $timeout Missing from Request to Pay start responses. + * @param list $tokens The recurring tokens of a card registration; active after the successful payment. */ public function __construct( public int $transactionId, public string $paymentUrl, public ?string $timeout = null, + public array $tokens = [], ) {} /** @@ -24,6 +26,7 @@ public static function fromArray(array $data): self transactionId: (int) $data['transactionId'], paymentUrl: $data['paymentUrl'], timeout: $data['timeout'] ?? null, + tokens: array_values(array_map(strval(...), (array) ($data['tokens'] ?? []))), ); } } diff --git a/src/Dto/RecurringRequest.php b/src/Dto/RecurringRequest.php index 81fce12..572f3b2 100644 --- a/src/Dto/RecurringRequest.php +++ b/src/Dto/RecurringRequest.php @@ -4,43 +4,61 @@ namespace Netipar\SimplePay\Dto; +use InvalidArgumentException; use Netipar\SimplePay\Enums\Currency; use Netipar\SimplePay\Enums\TransactionType; use Random\RandomException; +/** + * Recurring payment with a token (`dorecurring` endpoint). + */ readonly class RecurringRequest { /** + * @param int|null $transactionId Deprecated: the `dorecurring` endpoint identifies the card by $token; not sent, removed in the next major version. * @param array|null $items + * @param string|null $token The recurring token from the card registration. Required. + * @param string|null $threeDSReqAuthMethod How the customer is registered: 01 guest, 02 registered, 05 third party. */ public function __construct( public Currency $currency, - public int $transactionId, + public ?int $transactionId, public string $orderRef, public float $total, public string $customerEmail, public string $url, public TransactionType $type, public ?array $items = null, + public ?string $token = null, + public ?string $threeDSReqAuthMethod = null, + public ?string $customer = null, ) {} /** * @return array * * @throws RandomException + * @throws InvalidArgumentException When the recurring token is missing. */ public function toArray(): array { + if ($this->token === null || $this->token === '') { + throw new InvalidArgumentException('A recurring payment needs the recurring token: pass `token` to RecurringRequest.'); + } + return array_filter([ 'salt' => bin2hex(random_bytes(16)), 'merchant' => '', 'orderRef' => $this->orderRef, + 'methods' => ['CARD'], 'currency' => $this->currency->value, - 'transactionId' => $this->transactionId, + 'token' => $this->token, 'total' => $this->total, 'customerEmail' => $this->customerEmail, + 'customer' => $this->customer, 'url' => $this->url, 'type' => $this->type->value, + 'threeDSReqAuthMethod' => $this->threeDSReqAuthMethod, 'items' => $this->items ? array_map(fn (Item $item) => $item->toArray(), $this->items) : null, ], fn (mixed $value): bool => $value !== null); } diff --git a/src/Dto/RtpDoRequest.php b/src/Dto/RtpDoRequest.php index 341fe70..428dc8a 100644 --- a/src/Dto/RtpDoRequest.php +++ b/src/Dto/RtpDoRequest.php @@ -9,12 +9,19 @@ readonly class RtpDoRequest { + /** + * The default payment deadline of the batch transactions, in seconds (30 days, as in the official SDK). + */ + public const int DEFAULT_DEADLINE = 60 * 60 * 24 * 30; + /** * @param array $payments + * @param string|null $packageRef Unique reference of the batch in your system; generated when missing. */ public function __construct( public Currency $currency, public array $payments, + public ?string $packageRef = null, ) {} /** @@ -28,7 +35,11 @@ public function toArray(): array 'salt' => bin2hex(random_bytes(16)), 'merchant' => '', 'currency' => $this->currency->value, - 'payments' => array_map(fn (RtpPayment $payment) => $payment->toArray(), $this->payments), + 'packageRef' => $this->packageRef ?? 'pkg'.bin2hex(random_bytes(10)), + 'transactions' => array_map( + fn (RtpPayment $payment): array => [...$payment->toArray(), 'currency' => $this->currency->value], + $this->payments, + ), ]; } } diff --git a/src/Dto/RtpPayment.php b/src/Dto/RtpPayment.php index 9f86201..4efd36c 100644 --- a/src/Dto/RtpPayment.php +++ b/src/Dto/RtpPayment.php @@ -4,13 +4,25 @@ namespace Netipar\SimplePay\Dto; +/** + * One payment request of a Request to Pay batch (`rtp/do`). + */ readonly class RtpPayment { + /** + * @param string|null $deadline Payment deadline (ISO 8601); defaults to 30 days from now. + * @param string|null $additionalInfo The announcement shown with the bank transfer. + * @param string|null $customerBankAccount The customer's bank account number, when known. + */ public function __construct( public string $orderRef, public float $total, public string $customerEmail, public string $customer, + public ?string $deadline = null, + public ?string $additionalInfo = null, + public ?string $customerBankAccount = null, + public string $language = 'HU', ) {} /** @@ -18,11 +30,15 @@ public function __construct( */ public function toArray(): array { - return [ + return array_filter([ 'orderRef' => $this->orderRef, 'total' => $this->total, 'customerEmail' => $this->customerEmail, 'customer' => $this->customer, - ]; + 'deadline' => $this->deadline ?? date('c', time() + RtpDoRequest::DEFAULT_DEADLINE), + 'additionalInfo' => $this->additionalInfo, + 'customerBankAccount' => $this->customerBankAccount, + 'language' => $this->language, + ], fn (mixed $value): bool => $value !== null); } } diff --git a/src/Dto/RtpStartRequest.php b/src/Dto/RtpStartRequest.php index c95e20d..ff2dd41 100644 --- a/src/Dto/RtpStartRequest.php +++ b/src/Dto/RtpStartRequest.php @@ -9,9 +9,17 @@ readonly class RtpStartRequest { + /** + * The default payment deadline in seconds: 2 hours 5 minutes, as in the official SDK sample. + */ + public const int DEFAULT_DEADLINE = 300 + 7200; + /** * @param array|null $items * @param RedirectUrls|null $urls Event-specific redirect URLs, including `pending`; SimplePay ignores $url when they are sent. + * @param int|null $timeout Seconds until the payment deadline when $deadline is not given (default: DEFAULT_DEADLINE). + * @param string|null $deadline Payment deadline (ISO 8601). + * @param string|null $additionalInfo The announcement shown with the bank transfer. */ public function __construct( public Currency $currency, @@ -24,6 +32,9 @@ public function __construct( public ?array $items = null, public ?int $timeout = null, public ?RedirectUrls $urls = null, + public string $language = 'HU', + public ?string $deadline = null, + public ?string $additionalInfo = null, ) {} /** @@ -42,7 +53,8 @@ public function toArray(): array 'customerEmail' => $this->customerEmail, 'customer' => $this->customer, 'url' => $this->url, - 'timeout' => date('c', time() + ($this->timeout ?? config('simplepay.timeout', 600))), + 'language' => $this->language, + 'deadline' => $this->deadline ?? date('c', time() + ($this->timeout ?? self::DEFAULT_DEADLINE)), 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', ]; @@ -54,6 +66,10 @@ public function toArray(): array $data['items'] = array_map(fn (Item $item) => $item->toArray(), $this->items); } + if ($this->additionalInfo !== null) { + $data['additionalInfo'] = $this->additionalInfo; + } + if ($this->urls !== null) { $data['urls'] = $this->urls->toArray(); } diff --git a/src/Services/CardStorageService.php b/src/Services/CardStorageService.php index 5b51c00..1c3235e 100644 --- a/src/Services/CardStorageService.php +++ b/src/Services/CardStorageService.php @@ -64,6 +64,7 @@ public function cardQuery(Currency $currency, string $cardId, bool $history = fa $payload = [ 'salt' => bin2hex(random_bytes(16)), 'merchant' => $merchant, + 'currency' => $currency->value, 'cardId' => $cardId, 'history' => $history, ]; @@ -84,6 +85,7 @@ public function cardCancel(Currency $currency, string $cardId): array $payload = [ 'salt' => bin2hex(random_bytes(16)), 'merchant' => $merchant, + 'currency' => $currency->value, 'cardId' => $cardId, ]; @@ -103,6 +105,7 @@ public function tokenQuery(Currency $currency, string $token): array $payload = [ 'salt' => bin2hex(random_bytes(16)), 'merchant' => $merchant, + 'currency' => $currency->value, 'token' => $token, ]; @@ -122,6 +125,7 @@ public function tokenCancel(Currency $currency, string $token): array $payload = [ 'salt' => bin2hex(random_bytes(16)), 'merchant' => $merchant, + 'currency' => $currency->value, 'token' => $token, ]; diff --git a/src/Services/RtpService.php b/src/Services/RtpService.php index 2d0f369..4648e64 100644 --- a/src/Services/RtpService.php +++ b/src/Services/RtpService.php @@ -55,7 +55,7 @@ public function do(RtpDoRequest $request): array * * @throws RandomException|ConnectionException */ - public function query(Currency $currency, ?string $transactionId = null, ?string $orderRef = null): array + public function query(Currency $currency, ?string $transactionId = null, ?string $orderRef = null, ?string $packageRef = null): array { $resolved = $this->merchantResolver->resolve($currency); @@ -65,32 +65,43 @@ public function query(Currency $currency, ?string $transactionId = null, ?string ]; if ($transactionId !== null) { - $payload['transactionId'] = $transactionId; + $payload['transactionIds'] = [$transactionId]; } if ($orderRef !== null) { - $payload['orderRef'] = $orderRef; + $payload['orderRefs'] = [$orderRef]; + } + + if ($packageRef !== null) { + $payload['packageRef'] = $packageRef; } return $this->client->post('/rtp/query', $payload, $resolved['secret_key']); } /** + * @param float $amount The amount to refund (`refundTotal`). + * @param float|null $originalTotal The original amount of the transaction, as the official SDK sends it. * @return array * * @throws RandomException|ConnectionException */ - public function refund(Currency $currency, string $transactionId, float $amount): array + public function refund(Currency $currency, string $transactionId, float $amount, ?float $originalTotal = null): array { $resolved = $this->merchantResolver->resolve($currency); $payload = [ 'salt' => bin2hex(random_bytes(16)), 'merchant' => $resolved['merchant'], + 'currency' => $currency->value, 'transactionId' => $transactionId, - 'amount' => $amount, + 'refundTotal' => $amount, ]; + if ($originalTotal !== null) { + $payload['originalTotal'] = $originalTotal; + } + return $this->client->post('/rtp/refund', $payload, $resolved['secret_key']); } @@ -106,6 +117,7 @@ public function reverse(Currency $currency, string $transactionId): array $payload = [ 'salt' => bin2hex(random_bytes(16)), 'merchant' => $resolved['merchant'], + 'currency' => $currency->value, 'transactionId' => $transactionId, ]; diff --git a/src/Support/Client.php b/src/Support/Client.php index 2906f40..72aca6c 100644 --- a/src/Support/Client.php +++ b/src/Support/Client.php @@ -13,6 +13,16 @@ class Client { + /** + * The client identifier every request carries, as the official SDK sends it. + */ + public const string SDK_VERSION = 'SimplePay_PHP_SDK_2.1_Laravel'; + + /** + * Payload keys whose values never reach the log. + */ + private const array MASKED_KEYS = ['number', 'expiry', 'cvc', 'cardSecret', 'token', 'tokens', 'googlePayToken', 'applePayToken']; + /** * @param array $data * @return array @@ -23,6 +33,7 @@ class Client public function post(string $endpoint, array $data, string $merchantKey): array { $url = $this->buildUrl($endpoint); + $data['sdkVersion'] ??= self::SDK_VERSION; $json = json_encode($data, JSON_THROW_ON_ERROR); $signature = Signature::generate($merchantKey, $json); @@ -51,6 +62,7 @@ public function post(string $endpoint, array $data, string $merchantKey): array public function postSecurePay(string $endpoint, array $data, string $merchantKey): array { $url = $this->buildSecurePayUrl($endpoint); + $data['sdkVersion'] ??= self::SDK_VERSION; $json = json_encode($data, JSON_THROW_ON_ERROR); $signature = Signature::generate($merchantKey, $json); @@ -118,6 +130,23 @@ private function log(string $message, array $context = []): void return; } - Log::channel($channel)->debug($message, $context); + Log::channel($channel)->debug($message, $this->mask($context)); + } + + /** + * @param array $data + * @return array + */ + private function mask(array $data): array + { + foreach ($data as $key => $value) { + if (in_array($key, self::MASKED_KEYS, true)) { + $data[$key] = '[masked]'; + } elseif (is_array($value)) { + $data[$key] = $this->mask($value); + } + } + + return $data; } } diff --git a/tests/Unit/AutoPaymentServiceTest.php b/tests/Unit/AutoPaymentServiceTest.php index 0c126de..eb56679 100644 --- a/tests/Unit/AutoPaymentServiceTest.php +++ b/tests/Unit/AutoPaymentServiceTest.php @@ -57,7 +57,7 @@ function createAutoPaymentService(): AutoPaymentService && $body['currency'] === 'HUF' && $body['type'] === 'CIT' && $body['cardData']['number'] === '4111111111111111' - && $body['sdkVersion'] === 'netipar-simplepay:laravel' + && $body['sdkVersion'] === 'SimplePay_PHP_SDK_2.1_Laravel' && isset($body['salt']); }); }); @@ -125,13 +125,17 @@ function createAutoPaymentService(): AutoPaymentService Http::assertSent(function ($httpRequest) { $body = json_decode($httpRequest->body(), true); - return isset($body['browserData']) - && $body['browserData']['accept'] === 'text/html' - && $body['browserData']['agent'] === 'Mozilla/5.0' - && $body['browserData']['language'] === 'hu-HU' - && $body['browserData']['javaEnabled'] === false - && $body['browserData']['colorDepth'] === 24 - && $body['browserData']['windowSize'] === '05' + return $body['browser'] === [ + 'accept' => 'text/html', + 'agent' => 'Mozilla/5.0', + 'java' => false, + 'lang' => 'hu-HU', + 'color' => 24, + 'height' => 1080, + 'width' => 1920, + 'tz' => -60, + ] + && ! isset($body['browserData']) && $body['cardData']['holder'] === 'Test User'; }); }); diff --git a/tests/Unit/CardStorageServiceTest.php b/tests/Unit/CardStorageServiceTest.php index 7df549a..fff2df5 100644 --- a/tests/Unit/CardStorageServiceTest.php +++ b/tests/Unit/CardStorageServiceTest.php @@ -129,6 +129,7 @@ function createCardStorageService(): CardStorageService customerEmail: 'test@example.com', url: 'https://example.com/back', type: TransactionType::Mit, + token: 'token-abc-123', ); $response = createCardStorageService()->doRecurring($request); @@ -140,7 +141,8 @@ function createCardStorageService(): CardStorageService return str_contains($httpRequest->url(), '/v2/dorecurring') && $body['merchant'] === 'TEST_HUF_MERCHANT' - && $body['transactionId'] === 789012 + && $body['token'] === 'token-abc-123' + && ! isset($body['transactionId']) && $body['type'] === 'MIT' && isset($body['salt']); }); diff --git a/tests/Unit/PaymentServiceTest.php b/tests/Unit/PaymentServiceTest.php index 78da6a5..bfbaaf1 100644 --- a/tests/Unit/PaymentServiceTest.php +++ b/tests/Unit/PaymentServiceTest.php @@ -258,7 +258,8 @@ $data = $request->toArray(); expect($data['twoStep'])->toBeTrue(); - expect($data['shippingPrice'])->toBe(500.0); + expect($data['shippingCost'])->toBe(500.0) + ->and($data)->not->toHaveKey('shippingPrice'); expect($data['discount'])->toBe(100.0); expect($data['customer'])->toBe('CUST-001'); expect($data['methods'])->toBe(['CARD', 'WIRE']); @@ -280,7 +281,7 @@ $data = $request->toArray(); expect($data)->not->toHaveKey('twoStep'); - expect($data)->not->toHaveKey('shippingPrice'); + expect($data)->not->toHaveKey('shippingCost'); expect($data)->not->toHaveKey('discount'); expect($data)->not->toHaveKey('invoice'); expect($data)->not->toHaveKey('delivery'); diff --git a/tests/Unit/RequestPayloadTest.php b/tests/Unit/RequestPayloadTest.php new file mode 100644 index 0000000..98aa8d3 --- /dev/null +++ b/tests/Unit/RequestPayloadTest.php @@ -0,0 +1,475 @@ + Http::response([ + 'transactionId' => 123456, + 'orderRef' => 'ORD-1', + 'paymentUrl' => 'https://sandbox.simplepay.hu/pay/123456', + ])]); +}); + +/** + * @return list> + */ +function sentBodies(): array +{ + return Http::recorded() + ->map(fn (array $pair): array => json_decode($pair[0]->body(), true)) + ->values() + ->all(); +} + +/** + * @return array + */ +function sentBody(): array +{ + $bodies = sentBodies(); + + expect($bodies)->toHaveCount(1); + + return $bodies[0]; +} + +function browserData(): BrowserData +{ + return new BrowserData( + accept: 'text/html', + agent: 'Mozilla/5.0', + language: 'hu-HU', + javaEnabled: false, + colorDepth: 24, + screenHeight: 1080, + screenWidth: 1920, + timeZone: -120, + windowSize: '05', + ip: '203.0.113.10', + ); +} + +/** + * @return array + */ +function officialBrowserFields(): array +{ + return [ + 'accept' => 'text/html', + 'agent' => 'Mozilla/5.0', + 'ip' => '203.0.113.10', + 'java' => false, + 'lang' => 'hu-HU', + 'color' => 24, + 'height' => 1080, + 'width' => 1920, + 'tz' => -120, + ]; +} + +it('sends the sdk version and the currency with card and token queries and cancels', function () { + $service = app(CardStorageService::class); + + $service->cardQuery(Currency::HUF, 'card-1'); + $service->cardCancel(Currency::HUF, 'card-1'); + $service->tokenQuery(Currency::EUR, 'token-1'); + $service->tokenCancel(Currency::EUR, 'token-1'); + + $bodies = sentBodies(); + + expect(array_column($bodies, 'sdkVersion'))->toBe(array_fill(0, 4, 'SimplePay_PHP_SDK_2.1_Laravel')) + ->and(array_column($bodies, 'currency'))->toBe(['HUF', 'HUF', 'EUR', 'EUR']); +}); + +it('sends a OneClick payment with the official do fields', function () { + app(CardStorageService::class)->do(new CardStorageDoRequest( + currency: Currency::HUF, + transactionId: 123456, + cardId: 'card-1', + orderRef: 'ORD-1', + total: 1500.0, + customerEmail: 'test@example.com', + url: 'https://example.com/back', + cardSecret: 'secret', + threeDSReqAuthMethod: '02', + browser: browserData(), + )); + + $body = sentBody(); + + expect($body)->toMatchArray([ + 'cardId' => 'card-1', + 'cardSecret' => 'secret', + 'type' => 'CIT', + 'methods' => ['CARD'], + 'threeDSReqAuthMethod' => '02', + 'url' => 'https://example.com/back', + 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', + ]) + ->and($body['browser'])->toBe(officialBrowserFields()) + ->and($body)->not->toHaveKey('transactionId'); +}); + +it('sends a recurring payment with the recurring token', function () { + app(CardStorageService::class)->doRecurring(new RecurringRequest( + currency: Currency::HUF, + transactionId: 789012, + orderRef: 'SUB-1', + total: 9900.0, + customerEmail: 'test@example.com', + url: 'https://example.com/back', + type: TransactionType::Mit, + token: 'token-1', + threeDSReqAuthMethod: '02', + customer: 'Customer Name', + )); + + $body = sentBody(); + + expect($body)->toMatchArray([ + 'token' => 'token-1', + 'type' => 'MIT', + 'methods' => ['CARD'], + 'threeDSReqAuthMethod' => '02', + 'customer' => 'Customer Name', + 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', + ]) + ->and($body)->not->toHaveKey('transactionId'); +}); + +it('refuses a recurring payment without a recurring token', function () { + (new RecurringRequest( + currency: Currency::HUF, + transactionId: 789012, + orderRef: 'SUB-1', + total: 9900.0, + customerEmail: 'test@example.com', + url: 'https://example.com/back', + type: TransactionType::Mit, + ))->toArray(); +})->throws(InvalidArgumentException::class); + +it('sends an auto payment with the official browser data and 3DS fields', function () { + app(AutoPaymentService::class)->auto(new AutoPaymentRequest( + currency: Currency::HUF, + total: 100.0, + orderRef: 'AUTO-1', + customerEmail: 'test@example.com', + cardData: new CardData(cardNumber: '4908366099900425', expiry: '0130', cvc: '123', cardHolder: 'Test Holder'), + type: TransactionType::Cit, + browserData: browserData(), + url: 'https://example.com/back', + threeDSReqAuthMethod: '02', + language: 'HU', + )); + + $body = sentBody(); + + expect($body['browser'])->toBe(officialBrowserFields()) + ->and($body)->toMatchArray([ + 'url' => 'https://example.com/back', + 'threeDSReqAuthMethod' => '02', + 'language' => 'HU', + 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', + ]) + ->and($body)->not->toHaveKey('browserData'); +}); + +it('sends a Request to Pay start with language, deadline and announcement', function () { + app(RtpService::class)->start(new RtpStartRequest( + currency: Currency::HUF, + total: 5000.0, + orderRef: 'RTP-1', + customerEmail: 'test@example.com', + url: 'https://example.com/back', + customer: 'Customer Name', + language: 'EN', + deadline: '2026-11-01T12:00:00+01:00', + additionalInfo: 'Invoice 42', + )); + + expect(sentBody())->toMatchArray([ + 'language' => 'EN', + 'deadline' => '2026-11-01T12:00:00+01:00', + 'additionalInfo' => 'Invoice 42', + ]); +}); + +it('defaults the Request to Pay deadline to the two hours and five minutes of the official sample', function () { + app(RtpService::class)->start(new RtpStartRequest( + currency: Currency::HUF, + total: 5000.0, + orderRef: 'RTP-1', + customerEmail: 'test@example.com', + url: 'https://example.com/back', + customer: 'Customer Name', + )); + + $body = sentBody(); + + expect($body['language'])->toBe('HU') + ->and(strtotime($body['deadline']) - time())->toBeGreaterThanOrEqual(7490)->toBeLessThanOrEqual(7500) + ->and($body)->not->toHaveKey('timeout'); +}); + +it('uses the timeout seconds as the Request to Pay deadline', function () { + app(RtpService::class)->start(new RtpStartRequest( + currency: Currency::HUF, + total: 5000.0, + orderRef: 'RTP-1', + customerEmail: 'test@example.com', + url: 'https://example.com/back', + customer: 'Customer Name', + timeout: 3600, + )); + + expect(strtotime(sentBody()['deadline']) - time())->toBeGreaterThanOrEqual(3590)->toBeLessThanOrEqual(3600); +}); + +it('sends the shipping cost and the item description under their official names', function () { + app(PaymentService::class)->start(new PaymentRequest( + currency: Currency::HUF, + total: 2000.0, + orderRef: 'ORD-1', + customerEmail: 'test@example.com', + language: 'HU', + url: 'https://example.com/back', + items: [new Item(title: 'Product', price: 1500.0, description: 'Blue, size M')], + shippingPrice: 500.0, + )); + + $body = sentBody(); + + expect($body['shippingCost'])->toEqual(500.0) + ->and($body)->not->toHaveKey('shippingPrice') + ->and($body['items'][0]['desc'])->toBe('Blue, size M') + ->and($body['items'][0])->not->toHaveKey('description'); +}); + +it('keeps the recurring tokens of a card registration start', function () { + $response = PaymentResponse::fromArray([ + 'transactionId' => 99123344, + 'paymentUrl' => 'https://sandbox.simplepay.hu/pay/pay/pspHU/abc', + 'timeout' => '2026-10-18T12:00:00+02:00', + 'tokens' => ['SPT-1', 'SPT-2', 'SPT-3'], + ]); + + expect($response->tokens)->toBe(['SPT-1', 'SPT-2', 'SPT-3']); +}); + +it('sends a Request to Pay batch as a package of transactions', function () { + app(RtpService::class)->do(new RtpDoRequest( + currency: Currency::HUF, + payments: [ + new RtpPayment( + orderRef: 'RTP-1', + total: 1000.0, + customerEmail: 'a@example.com', + customer: 'Customer A', + deadline: '2026-11-30T12:00:00+01:00', + additionalInfo: 'Membership fee', + customerBankAccount: '002000041111111111111111', + ), + ], + packageRef: 'PKG-1', + )); + + $body = sentBody(); + + expect($body)->toHaveKey('packageRef', 'PKG-1') + ->not->toHaveKey('payments') + ->and($body['transactions'][0])->toMatchArray([ + 'orderRef' => 'RTP-1', + 'total' => 1000.0, + 'currency' => 'HUF', + 'customerEmail' => 'a@example.com', + 'customer' => 'Customer A', + 'deadline' => '2026-11-30T12:00:00+01:00', + 'additionalInfo' => 'Membership fee', + 'customerBankAccount' => '002000041111111111111111', + 'language' => 'HU', + ]); +}); + +it('generates the package reference and the deadlines of a Request to Pay batch when missing', function () { + app(RtpService::class)->do(new RtpDoRequest( + currency: Currency::HUF, + payments: [new RtpPayment(orderRef: 'RTP-1', total: 1000.0, customerEmail: 'a@example.com', customer: 'Customer A')], + )); + + $body = sentBody(); + + expect($body['packageRef'])->toStartWith('pkg') + ->and(strtotime($body['transactions'][0]['deadline']))->toBeGreaterThan(time() + 29 * 86400); +}); + +it('queries Request to Pay transactions with id lists and without currency', function () { + $service = app(RtpService::class); + + $service->query(Currency::HUF, transactionId: '168867693'); + $service->query(Currency::HUF, orderRef: 'RTP-1'); + $service->query(Currency::HUF, packageRef: 'PKG-1'); + + [$byId, $byOrderRef, $byPackage] = sentBodies(); + + expect($byId['transactionIds'])->toBe(['168867693']) + ->and($byId)->not->toHaveKey('transactionId') + ->and($byId)->not->toHaveKey('currency') + ->and($byOrderRef['orderRefs'])->toBe(['RTP-1']) + ->and($byPackage['packageRef'])->toBe('PKG-1') + ->and($byPackage['sdkVersion'])->toBe('SimplePay_PHP_SDK_2.1_Laravel'); +}); + +it('refunds a Request to Pay with the official refund fields', function () { + app(RtpService::class)->refund(Currency::HUF, '102264132', 15.0, originalTotal: 25.0); + + expect(sentBody())->toMatchArray([ + 'transactionId' => '102264132', + 'refundTotal' => 15.0, + 'originalTotal' => 25.0, + 'currency' => 'HUF', + ])->not->toHaveKey('amount'); +}); + +it('reverses a Request to Pay with the currency', function () { + app(RtpService::class)->reverse(Currency::EUR, '102264204'); + + expect(sentBody())->toMatchArray(['transactionId' => '102264204', 'currency' => 'EUR']); +}); + +it('starts an Apple Pay session with the registered domain', function () { + app(PaymentService::class)->startApplePay(new ApplePayStartRequest( + currency: Currency::HUF, + total: 10.0, + orderRef: 'AP-1', + customerEmail: 'test@example.com', + url: 'https://example.com/back', + domain: 'shop.example.com', + language: 'EN', + )); + + expect(sentBody())->toMatchArray([ + 'domain' => 'shop.example.com', + 'language' => 'EN', + 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', + ])->toHaveKey('timeout'); +}); + +it('uses the host of the app url as the default Apple Pay domain', function () { + config()->set('app.url', 'https://checkout.example.com/shop'); + + app(PaymentService::class)->startApplePay(new ApplePayStartRequest( + currency: Currency::HUF, + total: 10.0, + orderRef: 'AP-1', + customerEmail: 'test@example.com', + url: 'https://example.com/back', + )); + + expect(sentBody())->toMatchArray(['domain' => 'checkout.example.com', 'language' => 'HU']); +}); + +it('refuses an Apple Pay start when no domain can be resolved', function () { + config()->set('app.url', 'shop.example.com'); + + (new ApplePayStartRequest( + currency: Currency::HUF, + total: 10.0, + orderRef: 'AP-1', + customerEmail: 'test@example.com', + url: 'https://example.com/back', + ))->toArray(); +})->throws(InvalidArgumentException::class); + +it('completes an Apple Pay payment with the applePayToken field', function () { + app(PaymentService::class)->doApplePay(new ApplePayDoRequest( + currency: Currency::HUF, + transactionId: 581841992, + orderRef: 'AP-1', + token: 'apple-pay-token', + )); + + expect(sentBody())->toMatchArray([ + 'transactionId' => 581841992, + 'applePayToken' => 'apple-pay-token', + 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', + ])->not->toHaveKey('token'); +}); + +it('starts a qvik payment with the eam type, language and timeout', function () { + app(PaymentService::class)->startEam(new EamStartRequest( + currency: Currency::HUF, + total: 3000.0, + orderRef: 'EAM-1', + customerEmail: 'test@example.com', + url: 'https://example.com/back', + eamType: 4, + language: 'EN', + )); + + expect(sentBody())->toMatchArray([ + 'eamType' => 4, + 'language' => 'EN', + 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', + ])->toHaveKey('timeout'); +}); + +it('masks payment credentials in the request log', function () { + config()->set('simplepay.log_channel', 'simplepay'); + + $channel = Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel')->with('simplepay')->andReturn($channel); + + app(AutoPaymentService::class)->auto(new AutoPaymentRequest( + currency: Currency::HUF, + total: 100.0, + orderRef: 'AUTO-1', + customerEmail: 'test@example.com', + cardData: new CardData(cardNumber: '4908366099900425', expiry: '0130', cvc: '123', cardHolder: 'Test Holder'), + type: TransactionType::Cit, + )); + + $channel->shouldHaveReceived('debug')->withArgs(function (string $message, array $context): bool { + if ($message !== 'SimplePay securepay request') { + return false; + } + + expect($context['data']['cardData'])->toBe([ + 'number' => '[masked]', + 'expiry' => '[masked]', + 'cvc' => '[masked]', + 'holder' => 'Test Holder', + ]); + + return true; + })->once(); + + expect(sentBody()['cardData']['number'])->toBe('4908366099900425'); +}); diff --git a/tests/Unit/RtpServiceTest.php b/tests/Unit/RtpServiceTest.php index d507bdb..c40b457 100644 --- a/tests/Unit/RtpServiceTest.php +++ b/tests/Unit/RtpServiceTest.php @@ -96,7 +96,7 @@ }); }); -it('sends do request to /rtp/do with payments array', function () { +it('sends do request to /rtp/do with the transactions of the package', function () { $request = new RtpDoRequest( currency: Currency::HUF, payments: [ @@ -124,9 +124,9 @@ return str_contains($httpRequest->url(), '/rtp/do') && $httpRequest['merchant'] === 'TEST_HUF_MERCHANT' && $httpRequest['currency'] === 'HUF' - && count($httpRequest['payments']) === 2 - && $httpRequest['payments'][0]['orderRef'] === 'ORDER-001' - && $httpRequest['payments'][1]['orderRef'] === 'ORDER-002'; + && count($httpRequest['transactions']) === 2 + && $httpRequest['transactions'][0]['orderRef'] === 'ORDER-001' + && $httpRequest['transactions'][1]['orderRef'] === 'ORDER-002'; }); }); @@ -139,7 +139,7 @@ Http::assertSent(function ($httpRequest) { return str_contains($httpRequest->url(), '/rtp/query') && $httpRequest['merchant'] === 'TEST_HUF_MERCHANT' - && $httpRequest['transactionId'] === '123456'; + && $httpRequest['transactionIds'] === ['123456']; }); }); @@ -151,11 +151,11 @@ Http::assertSent(function ($httpRequest) { return str_contains($httpRequest->url(), '/rtp/query') && $httpRequest['merchant'] === 'TEST_HUF_MERCHANT' - && $httpRequest['orderRef'] === 'ORDER-001'; + && $httpRequest['orderRefs'] === ['ORDER-001']; }); }); -it('sends refund request to /rtp/refund with amount', function () { +it('sends refund request to /rtp/refund with the refund total', function () { $response = $this->rtpService->refund(Currency::HUF, '123456', 500.0); expect($response)->toBeArray() @@ -165,7 +165,7 @@ return str_contains($httpRequest->url(), '/rtp/refund') && $httpRequest['merchant'] === 'TEST_HUF_MERCHANT' && $httpRequest['transactionId'] === '123456' - && (float) $httpRequest['amount'] === 500.0; + && (float) $httpRequest['refundTotal'] === 500.0; }); });