diff --git a/AGENTS.md b/AGENTS.md index 1bad700..e5ab7e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,9 +30,9 @@ This repository is a Laravel package. Keep the package focused, idiomatic, and e ## SimplePay Architecture - 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), `CardStorageService` (OneClick `do`, recurring, card and token query/cancel), `AutoPaymentService` (merchant-side card data) and `RtpService` (Request to Pay). +- `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). +- `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`. - 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 1ff8791..02a3013 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,26 @@ $response->timeout; // Payment timeout return redirect($response->paymentUrl); ``` +### Event-Specific Redirect URLs + +SimplePay returns the customer to `url` after every outcome. Pass `urls` to use a separate page per outcome; SimplePay ignores `url` when `urls` is sent: + +```php +use Netipar\SimplePay\Dto\RedirectUrls; + +$response = SimplePay::payment()->start(new PaymentRequest( + // ... same as above + urls: new RedirectUrls( + success: route('payment.success'), + fail: route('payment.failed'), + cancel: route('checkout'), + timeout: route('payment.timeout'), + ), +)); +``` + +The order reference of a failed transaction can be sent again until the first successful payment; every start creates a new SimplePay transaction ID. + ### Two-Step Payment (Authorize + Finish) ```php @@ -261,6 +281,35 @@ $result = SimplePay::payment()->doApplePay(new ApplePayDoRequest( )); ``` +### Google Pay on Your Checkout Page + +Google Pay is also available on the SimplePay payment page with `PaymentMethod::CARD`. To show the Google Pay button on your own checkout page, render it with `googlePayConfig()` and charge the token from your backend: + +```php +use Netipar\SimplePay\Dto\GooglePayDoRequest; + +// Checkout page: the values of the Google Pay button +$googlePay = SimplePay::payment()->googlePayConfig(Currency::HUF, 5000); + +// Backend endpoint called by the button with the Base64 encoded Google Pay token +$validated = $request->validate(['googlePayToken' => ['required', 'string']]); + +$response = SimplePay::payment()->doGooglePay(new GooglePayDoRequest( + currency: Currency::HUF, + total: 5000, + orderRef: 'ORDER-GP-001', + customerEmail: 'customer@example.com', + googlePayToken: $validated['googlePayToken'], + url: route('simplepay.back'), // where SimplePay returns the customer after a 3DS challenge +)); + +if ($response->requiresChallenge()) { + // Send the browser to $response->redirectUrl +} +``` + +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). + ### EAM / Qvik Payment ```php @@ -287,6 +336,7 @@ $result = SimplePay::rtp()->start(new RtpStartRequest( orderRef: 'RTP-2026-001', customerEmail: 'customer@example.com', url: route('simplepay.back'), + customer: 'Customer Name', )); // Query RTP status @@ -299,6 +349,8 @@ SimplePay::rtp()->refund(Currency::HUF, $transactionId, 15000); SimplePay::rtp()->reverse(Currency::HUF, $transactionId); ``` +Request to Pay back redirects can carry `BackEvent::Pending`: the customer gave the bank account number, but the transfer has not arrived yet. `RtpStartRequest` accepts `urls` with a `pending` URL as well. + ## IPN Webhook The package automatically registers a `POST /simplepay/ipn` route with signature verification middleware. Listen to payment events in your application: @@ -362,6 +414,7 @@ class SimplePayBackResponse implements BackUrlResponse BackEvent::Fail => redirect()->route('payment.failed'), BackEvent::Cancel => redirect()->route('checkout'), BackEvent::Timeout => redirect()->route('payment.timeout'), + BackEvent::Pending => redirect()->route('payment.pending'), // Request to Pay only }; } } @@ -374,7 +427,7 @@ Always use enum cases instead of raw strings: - **`Currency`** -- `HUF`, `EUR`, `USD` - **`PaymentMethod`** -- `CARD`, `WIRE`, `EAM` - **`PaymentStatus`** -- `INIT`, `FINISHED`, `AUTHORIZED`, `NOTAUTHORIZED`, `INPAYMENT`, `CANCELLED`, `TIMEOUT`, `INFRAUD`, `FRAUD`, `REFUND`, `REVERSED` -- **`BackEvent`** -- `SUCCESS`, `FAIL`, `CANCEL`, `TIMEOUT` +- **`BackEvent`** -- `SUCCESS`, `FAIL`, `CANCEL`, `TIMEOUT`, `PENDING` (Request to Pay) - **`TransactionType`** -- `CIT` (Customer Initiated), `MIT` (Merchant Initiated), `REC` (Recurring) - **`ErrorCode`** -- 358+ error codes with Hungarian descriptions @@ -424,6 +477,8 @@ SIMPLEPAY_BACK_URL=https://yourdomain.com/payment/back SIMPLEPAY_TIMEOUT=600 SIMPLEPAY_LOG_CHANNEL=simplepay SIMPLEPAY_AUTO_CHALLENGE=true +SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID=your_google_pay_merchant_id +SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME="Your Shop" ``` Multi-currency support -- each currency has its own merchant credentials: @@ -457,7 +512,7 @@ Usage in Blade, Vue.js, or React: SimplePay ``` -Available logos: SimplePay (multiple sizes/orientations + white variants), Visa, Mastercard, Maestro, Amex, Apple Pay, Google Pay, Qvik, OTP SZÉP. Pre-composed card type combinations are also included. See [examples](examples/en/logos-and-assets.md) for Blade, Vue.js, and React checkout page examples. +Available logos: SimplePay (multiple sizes/orientations + white variants, and an SVG of the vertical logo), Visa, Mastercard, Maestro, Amex, Apple Pay, Google Pay, Qvik, OTP SZÉP. Pre-composed card type combinations are also included. See [examples](examples/en/logos-and-assets.md) for Blade, Vue.js, and React checkout page examples. ## Logging @@ -475,7 +530,7 @@ Set `SIMPLEPAY_LOG_CHANNEL` to any Laravel log channel name. Example dedicated c | Service | Access | Purpose | |---|---|---| -| `PaymentService` | `SimplePay::payment()` | Standard payments, refunds, queries, Apple Pay, EAM | +| `PaymentService` | `SimplePay::payment()` | Standard payments, refunds, queries, Apple Pay, Google Pay, EAM | | `CardStorageService` | `SimplePay::cardStorage()` | OneClick, recurring, card/token management | | `AutoPaymentService` | `SimplePay::autoPayment()` | PCI-DSS direct card payments | | `RtpService` | `SimplePay::rtp()` | Request to Pay (bank transfers) | @@ -484,6 +539,8 @@ Set `SIMPLEPAY_LOG_CHANNEL` to any Laravel log channel name. Example dedicated c This package is built based on the **SimplePay PHP SDK v2.1** (API v2). All API requests include the `sdkVersion: SimplePay_PHP_SDK_2.1_Laravel` identifier. +The merchant-side Google Pay flow follows the Payment PHP SDK 2.1.5 (2026-06-27) and the SimplePay Google Pay documentation (2026-09-01); the `urls` and `pending` handling follows the Request to Pay documentation (2026-08-17). + ## Examples - [English examples](examples/en/) diff --git a/config/simplepay.php b/config/simplepay.php index 317ca87..dac4454 100644 --- a/config/simplepay.php +++ b/config/simplepay.php @@ -105,6 +105,22 @@ 'log_channel' => env('SIMPLEPAY_LOG_CHANNEL'), + /* + |-------------------------------------------------------------------------- + | Google Pay + |-------------------------------------------------------------------------- + | + | Merchant-side Google Pay acceptance through SimplePay. The merchant ID + | and name come from the Google Pay & Wallet Console; the checkout page + | needs them to render the Google Pay button. + | + */ + + 'google_pay' => [ + 'merchant_id' => env('SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID'), + 'merchant_name' => env('SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME'), + ], + /* |-------------------------------------------------------------------------- | Auto Challenge diff --git a/examples/en/back-url.md b/examples/en/back-url.md index 3fa87eb..a68b01c 100644 --- a/examples/en/back-url.md +++ b/examples/en/back-url.md @@ -37,6 +37,7 @@ class SimplePayBackResponse implements BackUrlResponse BackEvent::Fail => redirect()->route('payment.failed'), BackEvent::Cancel => redirect()->route('payment.cancelled'), BackEvent::Timeout => redirect()->route('payment.timeout'), + BackEvent::Pending => redirect()->route('payment.pending'), }; } } @@ -81,6 +82,9 @@ The `BackResponse` object contains the following properties: | `BackEvent::Fail` | Payment failed | | `BackEvent::Cancel` | Customer cancelled the payment | | `BackEvent::Timeout` | Payment timed out | +| `BackEvent::Pending` | Request to Pay only: the bank account number is given, the transfer has not arrived yet | + +A `match` over `$back->event` has to cover `BackEvent::Pending` as well, otherwise a Request to Pay redirect throws an `UnhandledMatchError`. ## Example: Advanced Handling @@ -102,6 +106,7 @@ class SimplePayBackResponse implements BackUrlResponse BackEvent::Fail => $this->handleFailure($back), BackEvent::Cancel => $this->handleCancel($back), BackEvent::Timeout => $this->handleTimeout($back), + BackEvent::Pending => response()->view('payment.pending', ['orderRef' => $back->orderRef]), }; } diff --git a/examples/en/configuration.md b/examples/en/configuration.md index 30f69a6..1564f35 100644 --- a/examples/en/configuration.md +++ b/examples/en/configuration.md @@ -21,6 +21,8 @@ SIMPLEPAY_EUR_SECRET_KEY=your_eur_secret_key SIMPLEPAY_BACK_URL=https://yourdomain.com/payment/back SIMPLEPAY_LOG_CHANNEL=simplepay SIMPLEPAY_TIMEOUT=600 +SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID=your_google_pay_merchant_id +SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME="Your Shop" ``` ## Configuration Structure @@ -109,6 +111,20 @@ return [ */ 'log_channel' => env('SIMPLEPAY_LOG_CHANNEL'), + /* + |-------------------------------------------------------------------------- + | Google Pay + |-------------------------------------------------------------------------- + | + | The Google Pay merchant ID and name for the Google Pay button on your + | own checkout page. + | + */ + 'google_pay' => [ + 'merchant_id' => env('SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID'), + 'merchant_name' => env('SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME'), + ], + /* |-------------------------------------------------------------------------- | Auto Challenge @@ -173,3 +189,7 @@ The default value is 600 seconds (10 minutes). ## Auto Challenge When `auto_challenge` is enabled, the package automatically handles 3D Secure challenge flows during card payments. This is enabled by default and is recommended for most use cases. + +## Google Pay + +`google_pay.merchant_id` and `google_pay.merchant_name` come from the Google Pay & Wallet Console. They are only needed when the Google Pay button is on your own checkout page; see [Google Pay on Your Checkout Page](google-pay.md). diff --git a/examples/en/google-pay.md b/examples/en/google-pay.md new file mode 100644 index 0000000..061c3fa --- /dev/null +++ b/examples/en/google-pay.md @@ -0,0 +1,221 @@ +# Google Pay on Your Checkout Page + +Customers can already pay with Google Pay on the SimplePay payment page (`PaymentMethod::CARD`). This page covers the merchant-side flow that SimplePay introduced in 2026: the Google Pay button sits on your own checkout page, and your backend charges the Google Pay token with `SimplePay::payment()->doGooglePay()`. + +The flow follows the official SimplePay Google Pay documentation (2026-09-01) and the `dogooglepay.php` sample of the SimplePay PHP SDK 2.1.5. + +## Prerequisites + +- Google Pay is enabled for your SimplePay merchant account. +- You have a Google Pay merchant ID from the Google Pay & Wallet Console, and Google has approved your website (HTTPS domain, screenshots of the checkout). Send the merchant ID, the domain and the screenshots to SimplePay support. +- The checkout follows the Google Pay [brand guidelines](https://developers.google.com/pay/api/web/guides/brand-guidelines) and the [integration checklist](https://developers.google.com/pay/api/web/guides/test-and-deploy/integration-checklist). + +## Configuration + +```env +SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID=your_google_pay_merchant_id +SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME="Your Shop" +``` + +The SimplePay merchant credentials of the currency are used as for any other payment. + +## The Flow + +1. The checkout page renders the Google Pay button with the values of `googlePayConfig()`. +2. After the customer approves the payment in the Google Pay sheet, the page posts the Base64 encoded Google Pay token to your backend. +3. Your backend calls `doGooglePay()` and returns the result to the page. +4. Without a 3DS challenge the customer stays on the checkout page, and SimplePay makes no back redirect. Show the result on the page. +5. If the card issuer asks for a 3DS challenge, the response has a `redirectUrl`: send the browser there. After the challenge SimplePay redirects the customer to the `url` of the request with the signed `r` and `s` parameters, which the package's `simplepay.back` route handles. +6. Fulfil the order from the IPN (or a `query()`), never from the synchronous response or the back redirect alone. + +## Backend + +```php +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Netipar\SimplePay\Dto\GooglePayDoRequest; +use Netipar\SimplePay\Enums\Currency; +use Netipar\SimplePay\Exceptions\SimplePayApiException; +use Netipar\SimplePay\Facades\SimplePay; + +class GooglePayController +{ + public function __invoke(Request $request, Order $order): JsonResponse + { + $validated = $request->validate([ + 'googlePayToken' => ['required', 'string'], + ]); + + try { + $response = SimplePay::payment()->doGooglePay(new GooglePayDoRequest( + currency: Currency::HUF, + total: $order->total, + orderRef: $order->reference, + customerEmail: $order->email, + googlePayToken: $validated['googlePayToken'], + url: route('simplepay.back'), + threeDSReqAuthMethod: '02', + )); + } catch (SimplePayApiException $e) { + return response()->json(['errorCodes' => $e->getErrorCodes()], 422); + } + + return response()->json([ + 'transactionId' => $response->transactionId, + 'redirectUrl' => $response->redirectUrl, + ]); + } +} +``` + +Take the amount, the order reference and the return `url` from your own data, not from the browser. + +### Request Fields + +| Field | Description | +|---|---| +| `currency`, `total`, `orderRef`, `customerEmail` | As for a normal payment | +| `googlePayToken` | `paymentMethodData.tokenizationData.token` from Google Pay, Base64 encoded (`btoa()` in the browser) | +| `url` | Where SimplePay sends the customer after a 3DS challenge | +| `language` | Defaults to `HU` | +| `type` | Defaults to `TransactionType::Cit` | +| `threeDSReqAuthMethod` | How the customer is registered: `01` guest, `02` registered, `05` third-party login | +| `invoice`, `customer` | Optional billing data | +| `timeout` | In seconds; defaults to `simplepay.timeout` | + +### Response + +| Property | Description | +|---|---| +| `$response->transactionId` | SimplePay transaction ID | +| `$response->orderRef` | Your order reference | +| `$response->currency`, `$response->total` | When SimplePay returns them | +| `$response->redirectUrl` | Set only when a 3DS challenge is required | +| `$response->requiresChallenge()` | `true` when `redirectUrl` is set | + +A 3DS challenge (error code `3004`) is a normal outcome and does not throw. Every other error code throws `SimplePayApiException`. + +## Frontend + +Pass the button configuration to the checkout page: + +```php +return view('checkout', [ + 'order' => $order, + 'googlePay' => SimplePay::payment()->googlePayConfig(Currency::HUF, $order->total), +]); +``` + +| Key | Value | +|---|---| +| `gpayMerchantId`, `gpayMerchantName` | `simplepay.google_pay.merchant_id` and `merchant_name` | +| `gpayEnvironment` | `TEST` in sandbox mode, `PRODUCTION` otherwise | +| `gateway` | `simplepay` | +| `gatewayMerchantId` | Your SimplePay merchant ID for the currency | +| `paymentType` | `CARD` | +| `total`, `currency` | The amount shown in the Google Pay sheet | + +A minimal Blade page, based on the official sample: + +```blade +
+ + + +``` + +In an Inertia (Vue or React) page pass `googlePayConfig()` as a prop, load `pay.js` when the component mounts, and use the same request objects. + +## Testing + +In sandbox mode the button runs in Google's `TEST` environment. The official checklist asks you to test: + +- a successful payment without 3DS, staying on the checkout page; +- a failed payment without 3DS; +- the `3004` response and the redirect to `redirectUrl`; +- a successful 3DS challenge and the return to `url`; +- a failed, cancelled and expired 3DS challenge; +- a missing or invalid `r`/`s` parameter; +- the back redirect and the IPN arriving in either order; +- a customer who never returns to your site. diff --git a/examples/en/logos-and-assets.md b/examples/en/logos-and-assets.md index f4abc60..dd7216e 100644 --- a/examples/en/logos-and-assets.md +++ b/examples/en/logos-and-assets.md @@ -22,6 +22,7 @@ public/vendor/simplepay/ │ ├── simplepay_horizontal_01.png # Horizontal logo v1 │ ├── simplepay_horizontal_02.png # Horizontal logo v2 │ ├── simplepay_vertical.png # Vertical logo +│ ├── simplepay_vertical.svg # Vertical logo (SVG) │ ├── simplepay_top.png # Top-placed logo │ ├── simplepay_*-white.png # White variants (for dark backgrounds) │ ├── visa_logo_color.png # Visa (color) diff --git a/examples/en/payment-start.md b/examples/en/payment-start.md index 820cf61..429879a 100644 --- a/examples/en/payment-start.md +++ b/examples/en/payment-start.md @@ -43,7 +43,7 @@ return redirect($response->paymentUrl); |---|---|---| | `$response->transactionId` | `int` | SimplePay transaction ID | | `$response->paymentUrl` | `string` | URL to redirect the customer to | -| `$response->timeout` | `string` | Payment timeout | +| `$response->timeout` | `?string` | Payment timeout (missing from Request to Pay starts) | ## Two-Step Payment (Preauthorization) @@ -154,6 +154,35 @@ $response = SimplePay::payment()->start(new PaymentRequest( return redirect($response->paymentUrl); ``` +## Event-Specific Redirect URLs + +By default SimplePay sends the customer back to `url` after every outcome. To use a separate page per outcome, pass `urls`. SimplePay ignores `url` when `urls` is sent, so give all four events. + +```php +use Netipar\SimplePay\Dto\RedirectUrls; + +$response = SimplePay::payment()->start(new PaymentRequest( + currency: Currency::HUF, + total: 2500, + orderRef: 'ORDER-124', + customerEmail: 'customer@example.com', + language: 'HU', + url: route('simplepay.back'), + urls: new RedirectUrls( + success: route('payment.success'), + fail: route('payment.failed'), + cancel: route('checkout'), + timeout: route('payment.timeout'), + ), +)); +``` + +These pages receive the signed `r` and `s` query parameters too; verify them with `SimplePay::payment()->handleBack($request->query('r'), $request->query('s'))`. + +## Reusing an Order Reference + +`orderRef` identifies the order in your system. The order reference of a failed transaction can be sent again until the first successful payment; every new start creates a new SimplePay `transactionId`, so store the transaction ID of each attempt. + ## Available Payment Methods | Enum | Description | diff --git a/examples/en/rtp.md b/examples/en/rtp.md index f495c0e..5fa3d8f 100644 --- a/examples/en/rtp.md +++ b/examples/en/rtp.md @@ -19,8 +19,38 @@ $response = SimplePay::rtp()->start(new RtpStartRequest( url: route('simplepay.back'), customer: 'Test Customer', )); + +$response->paymentUrl; // Redirect the customer here +$response->transactionId; // SimplePay transaction ID +$response->timeout; // null: RTP start responses have no timeout ``` +### Event-Specific Redirect URLs + +Request to Pay adds a `pending` event to the card payment events: the customer gave the bank account number, but the transfer has not arrived yet. SimplePay ignores `url` when `urls` is sent. + +```php +use Netipar\SimplePay\Dto\RedirectUrls; + +$response = SimplePay::rtp()->start(new RtpStartRequest( + currency: Currency::HUF, + total: 5000, + orderRef: 'RTP-ORDER-002', + customerEmail: 'customer@example.com', + url: route('simplepay.back'), + customer: 'Test Customer', + urls: new RedirectUrls( + success: route('payment.success'), + fail: route('payment.failed'), + cancel: route('checkout'), + timeout: route('payment.timeout'), + pending: route('payment.pending'), + ), +)); +``` + +On the package's `simplepay.back` route the pending redirect arrives as `BackEvent::Pending`. + ## Batch RTP Send multiple payment requests at once using the batch RTP method. diff --git a/examples/hu/fizetes-inditas.md b/examples/hu/fizetes-inditas.md index d250ce6..2d69be6 100644 --- a/examples/hu/fizetes-inditas.md +++ b/examples/hu/fizetes-inditas.md @@ -44,7 +44,7 @@ A `start()` metódus által visszaadott válasz a következő adatokat tartalmaz ```php $response->transactionId; // SimplePay tranzakció azonosító (int) $response->paymentUrl; // Fizetési oldal URL, ahova a vásárlót át kell irányítani -$response->timeout; // Időtúllépés másodpercben +$response->timeout; // A fizetés indításának határideje (Request to Pay indításnál null) ``` ## Kétlépcsős fizetés (preautorizáció) @@ -166,6 +166,35 @@ $response = SimplePay::payment()->start(new PaymentRequest( )); ``` +## Eseményenkénti visszairányítási címek + +Alapesetben a SimplePay minden kimenet után az `url` címre irányítja vissza a vásárlót. Ha kimenetenként külön oldalt szeretnél, add meg az `urls` mezőt. Ha az `urls` szerepel a kérésben, a SimplePay figyelmen kívül hagyja az `url`-t, ezért mind a négy eseményt meg kell adni. + +```php +use Netipar\SimplePay\Dto\RedirectUrls; + +$response = SimplePay::payment()->start(new PaymentRequest( + currency: Currency::HUF, + total: 2500, + orderRef: 'ORDER-124', + customerEmail: 'vasarlo@example.com', + language: 'HU', + url: route('simplepay.back'), + urls: new RedirectUrls( + success: route('payment.success'), + fail: route('payment.failed'), + cancel: route('checkout'), + timeout: route('payment.timeout'), + ), +)); +``` + +Ezek az oldalak is megkapják az aláírt `r` és `s` paramétert; ellenőrizd őket a `SimplePay::payment()->handleBack($request->query('r'), $request->query('s'))` hívással. + +## Rendelésazonosító újrahasználata + +Az `orderRef` a rendelést azonosítja a rendszeredben. Egy sikertelen tranzakció rendelésazonosítója az első sikeres fizetésig újra elküldhető; minden új indítás új SimplePay `transactionId`-t kap, ezért a tranzakcióazonosítót próbálkozásonként tárold. + ## Fizetési módszerek A `methods` tömbben megadható értékek: diff --git a/examples/hu/google-pay.md b/examples/hu/google-pay.md new file mode 100644 index 0000000..b520839 --- /dev/null +++ b/examples/hu/google-pay.md @@ -0,0 +1,221 @@ +# Google Pay a saját fizetési oldalon + +A vásárlók eddig is fizethettek Google Pay-jel a SimplePay fizetőoldalán (`PaymentMethod::CARD`). Ez az oldal a SimplePay 2026-ban bevezetett, kereskedő oldali megoldását mutatja be: a Google Pay gomb a saját fizetési oldaladon jelenik meg, a backend pedig a `SimplePay::payment()->doGooglePay()` hívással terheli meg a Google Pay tokent. + +A folyamat a SimplePay hivatalos Google Pay dokumentációját (2026.09.01.) és a SimplePay PHP SDK 2.1.5 `dogooglepay.php` mintáját követi. + +## Előfeltételek + +- A SimplePay kereskedői fiókodon engedélyezve van a Google Pay. +- Van Google Pay kereskedői azonosítód a Google Pay & Wallet Console-ból, és a Google jóváhagyta a weboldaladat (HTTPS domain, képernyőképek a fizetési folyamatról). Az azonosítót, a domaint és a képernyőképeket a SimplePay ügyfélszolgálatának kell elküldeni. +- A fizetési oldal megfelel a Google Pay [márkairányelveinek](https://developers.google.com/pay/api/web/guides/brand-guidelines) és [integrációs ellenőrzőlistájának](https://developers.google.com/pay/api/web/guides/test-and-deploy/integration-checklist). + +## Konfiguráció + +```env +SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID=google_pay_kereskedoi_azonosito +SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME="Webáruházad neve" +``` + +A pénznemhez tartozó SimplePay kereskedői adatokat ugyanúgy használja, mint bármely más fizetésnél. + +## A folyamat + +1. A fizetési oldal a `googlePayConfig()` értékeivel jeleníti meg a Google Pay gombot. +2. Miután a vásárló jóváhagyta a fizetést a Google Pay felületén, az oldal elküldi a Base64-kódolt Google Pay tokent a backendnek. +3. A backend meghívja a `doGooglePay()`-t, és visszaadja az eredményt az oldalnak. +4. 3DS challenge nélkül a vásárló a fizetési oldalon marad, és a SimplePay nem irányítja vissza. Az eredményt az oldalon kell megjeleníteni. +5. Ha a kártyakibocsátó 3DS challenge-et kér, a válaszban `redirectUrl` érkezik, ide kell átirányítani a böngészőt. A challenge után a SimplePay a kérés `url` címére irányítja vissza a vásárlót az aláírt `r` és `s` paraméterekkel; ezeket a csomag `simplepay.back` route-ja kezeli. +6. A rendelést az IPN (vagy egy `query()`) alapján teljesítsd, soha ne csak a szinkron válasz vagy a visszairányítás alapján. + +## Backend + +```php +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Netipar\SimplePay\Dto\GooglePayDoRequest; +use Netipar\SimplePay\Enums\Currency; +use Netipar\SimplePay\Exceptions\SimplePayApiException; +use Netipar\SimplePay\Facades\SimplePay; + +class GooglePayController +{ + public function __invoke(Request $request, Order $order): JsonResponse + { + $validated = $request->validate([ + 'googlePayToken' => ['required', 'string'], + ]); + + try { + $response = SimplePay::payment()->doGooglePay(new GooglePayDoRequest( + currency: Currency::HUF, + total: $order->total, + orderRef: $order->reference, + customerEmail: $order->email, + googlePayToken: $validated['googlePayToken'], + url: route('simplepay.back'), + threeDSReqAuthMethod: '02', + )); + } catch (SimplePayApiException $e) { + return response()->json(['errorCodes' => $e->getErrorCodes()], 422); + } + + return response()->json([ + 'transactionId' => $response->transactionId, + 'redirectUrl' => $response->redirectUrl, + ]); + } +} +``` + +Az összeget, a rendelésazonosítót és a visszatérési `url`-t a saját adataidból vedd, ne a böngészőből. + +### A kérés mezői + +| Mező | Leírás | +|---|---| +| `currency`, `total`, `orderRef`, `customerEmail` | Mint egy normál fizetésnél | +| `googlePayToken` | A Google Pay `paymentMethodData.tokenizationData.token` értéke Base64-kódolva (a böngészőben `btoa()`) | +| `url` | Ide irányítja vissza a SimplePay a vásárlót a 3DS challenge után | +| `language` | Alapértéke `HU` | +| `type` | Alapértéke `TransactionType::Cit` | +| `threeDSReqAuthMethod` | A vásárló regisztrációja: `01` vendég, `02` regisztrált, `05` harmadik feles belépés | +| `invoice`, `customer` | Opcionális számlázási adatok | +| `timeout` | Másodpercben; alapértéke a `simplepay.timeout` | + +### A válasz + +| Tulajdonság | Leírás | +|---|---| +| `$response->transactionId` | SimplePay tranzakcióazonosító | +| `$response->orderRef` | A rendelésazonosítód | +| `$response->currency`, `$response->total` | Ha a SimplePay visszaadja | +| `$response->redirectUrl` | Csak akkor van értéke, ha 3DS challenge szükséges | +| `$response->requiresChallenge()` | `true`, ha van `redirectUrl` | + +A 3DS challenge (`3004`-es hibakód) normál kimenet, nem dob kivételt. Minden más hibakód `SimplePayApiException`-t dob. + +## Frontend + +Add át a gomb beállításait a fizetési oldalnak: + +```php +return view('checkout', [ + 'order' => $order, + 'googlePay' => SimplePay::payment()->googlePayConfig(Currency::HUF, $order->total), +]); +``` + +| Kulcs | Érték | +|---|---| +| `gpayMerchantId`, `gpayMerchantName` | `simplepay.google_pay.merchant_id` és `merchant_name` | +| `gpayEnvironment` | Sandbox módban `TEST`, egyébként `PRODUCTION` | +| `gateway` | `simplepay` | +| `gatewayMerchantId` | A pénznemhez tartozó SimplePay kereskedői azonosító | +| `paymentType` | `CARD` | +| `total`, `currency` | A Google Pay felületen megjelenő összeg | + +Egy minimális Blade-oldal a hivatalos minta alapján: + +```blade +
+ + + +``` + +Inertia-oldalon (Vue vagy React) a `googlePayConfig()` eredményét propként add át, a `pay.js`-t a komponens betöltésekor töltsd be, és ugyanezeket a kérésobjektumokat használd. + +## Tesztelés + +Sandbox módban a gomb a Google `TEST` környezetében fut. A hivatalos ellenőrzőlista szerint ezeket kell tesztelni: + +- sikeres fizetés 3DS nélkül, a fizetési oldalon maradva; +- sikertelen fizetés 3DS nélkül; +- a `3004`-es válasz és az átirányítás a `redirectUrl` címre; +- sikeres 3DS challenge és a visszatérés az `url` címre; +- sikertelen, megszakított és lejárt 3DS challenge; +- hiányzó vagy hibás `r`/`s` paraméter; +- a visszairányítás és az IPN tetszőleges sorrendben érkezik; +- a vásárló nem tér vissza az oldaladra. diff --git a/examples/hu/konfiguracio.md b/examples/hu/konfiguracio.md index afdcde0..35dda24 100644 --- a/examples/hu/konfiguracio.md +++ b/examples/hu/konfiguracio.md @@ -21,6 +21,8 @@ SIMPLEPAY_EUR_SECRET_KEY=your_eur_secret_key SIMPLEPAY_BACK_URL=https://yourdomain.com/payment/back SIMPLEPAY_LOG_CHANNEL=simplepay SIMPLEPAY_TIMEOUT=600 +SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID=your_google_pay_merchant_id +SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME="Your Shop" ``` ## Konfigurációs struktúra @@ -111,6 +113,20 @@ return [ */ 'timeout' => env('SIMPLEPAY_TIMEOUT', 600), + /* + |-------------------------------------------------------------------------- + | Google Pay + |-------------------------------------------------------------------------- + | + | A Google Pay kereskedői azonosító és név a saját fizetési oldalon + | megjelenő Google Pay gombhoz. + | + */ + 'google_pay' => [ + 'merchant_id' => env('SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID'), + 'merchant_name' => env('SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME'), + ], + /* |-------------------------------------------------------------------------- | Auto challenge (3DS) @@ -197,3 +213,7 @@ Az `auto_challenge` beállítás szabályozza, hogy a package automatikusan kér ```env SIMPLEPAY_AUTO_CHALLENGE=true ``` + +## Google Pay + +A `google_pay.merchant_id` és a `google_pay.merchant_name` értéke a Google Pay & Wallet Console-ból származik. Csak akkor kell, ha a Google Pay gomb a saját fizetési oldaladon jelenik meg; lásd [Google Pay a saját fizetési oldalon](google-pay.md). diff --git a/examples/hu/logok-es-asetek.md b/examples/hu/logok-es-asetek.md index d3a7fc7..b8ec0e7 100644 --- a/examples/hu/logok-es-asetek.md +++ b/examples/hu/logok-es-asetek.md @@ -22,6 +22,7 @@ public/vendor/simplepay/ │ ├── simplepay_horizontal_01.png # Fekvő logó v1 │ ├── simplepay_horizontal_02.png # Fekvő logó v2 │ ├── simplepay_vertical.png # Álló logó +│ ├── simplepay_vertical.svg # Álló logó (SVG) │ ├── simplepay_top.png # Felül elhelyezett logó │ ├── simplepay_*-white.png # Fehér változatok (sötét háttérhez) │ ├── visa_logo_color.png # Visa (színes) diff --git a/examples/hu/rtp.md b/examples/hu/rtp.md index 4c5b0de..33f3866 100644 --- a/examples/hu/rtp.md +++ b/examples/hu/rtp.md @@ -23,8 +23,35 @@ $response = SimplePay::rtp()->start(new RtpStartRequest( // Válasz adatok $response->paymentUrl; // URL a fizetési oldalhoz $response->transactionId; // SimplePay tranzakció azonosító +$response->timeout; // null: az RTP indítás válaszában nincs timeout ``` +### Eseményenkénti visszairányítási címek + +A Request to Pay a kártyás események mellé egy `pending` eseményt is ad: a vásárló már megadta a bankszámlaszámát, de az utalás még nem érkezett meg. Ha az `urls` szerepel a kérésben, a SimplePay figyelmen kívül hagyja az `url`-t. + +```php +use Netipar\SimplePay\Dto\RedirectUrls; + +$response = SimplePay::rtp()->start(new RtpStartRequest( + currency: Currency::HUF, + total: 5000, + orderRef: 'RTP-ORDER-002', + customerEmail: 'vasarlo@example.com', + url: route('simplepay.back'), + customer: 'Teszt Vásárló', + urls: new RedirectUrls( + success: route('payment.success'), + fail: route('payment.failed'), + cancel: route('checkout'), + timeout: route('payment.timeout'), + pending: route('payment.pending'), + ), +)); +``` + +A csomag `simplepay.back` route-ján a pending visszairányítás `BackEvent::Pending` eseményként érkezik. + ## Kötegelt RTP Több fizetési kérés egyidejűleg történő küldése: diff --git a/examples/hu/visszaterites.md b/examples/hu/visszaterites.md index 96f9746..d36e05a 100644 --- a/examples/hu/visszaterites.md +++ b/examples/hu/visszaterites.md @@ -37,6 +37,7 @@ class SimplePayBackResponse implements BackUrlResponse BackEvent::Fail => redirect()->route('payment.failed'), BackEvent::Cancel => redirect()->route('payment.cancelled'), BackEvent::Timeout => redirect()->route('payment.timeout'), + BackEvent::Pending => redirect()->route('payment.pending'), }; } } @@ -66,7 +67,7 @@ class AppServiceProvider extends ServiceProvider A `BackResponse` objektum az alábbi tulajdonságokat tartalmazza: ```php -$back->event; // BackEvent enum (Success, Fail, Cancel, Timeout) +$back->event; // BackEvent enum (Success, Fail, Cancel, Timeout, Pending) $back->transactionId; // SimplePay tranzakció azonosító (int) $back->orderRef; // Az eredeti megrendelési hivatkozás (string) $back->merchant; // Kereskedői azonosító (string) @@ -81,6 +82,9 @@ $back->total; // A fizetés összege (float) | `BackEvent::Fail` | A fizetés sikertelen volt (pl. elutasított kártya) | | `BackEvent::Cancel` | A vásárló megszakította a fizetést | | `BackEvent::Timeout` | A fizetés időtúllépésbe futott, a vásárló nem fejezte be | +| `BackEvent::Pending` | Csak Request to Pay: a bankszámlaszám megvan, az utalás még nem érkezett meg | + +A `$back->event`-re írt `match`-nek a `BackEvent::Pending` esetet is kezelnie kell, különben egy Request to Pay visszairányítás `UnhandledMatchError`-t dob. ## Példa: összetett kezelés @@ -102,6 +106,7 @@ class SimplePayBackResponse implements BackUrlResponse BackEvent::Fail => $this->handleFailure($back), BackEvent::Cancel => $this->handleCancel($back), BackEvent::Timeout => $this->handleTimeout($back), + BackEvent::Pending => response()->view('payment.pending', ['orderRef' => $back->orderRef]), }; } diff --git a/official-docs/en/SimplePay_GooglePay_EN.pdf b/official-docs/en/SimplePay_GooglePay_EN.pdf new file mode 100644 index 0000000..e1140c8 Binary files /dev/null and b/official-docs/en/SimplePay_GooglePay_EN.pdf differ diff --git a/official-docs/en/SimplePay_v21_Developer_Documentation_EN.pdf b/official-docs/en/SimplePay_v21_Developer_Documentation_EN.pdf index 5c94fe6..649d907 100644 Binary files a/official-docs/en/SimplePay_v21_Developer_Documentation_EN.pdf and b/official-docs/en/SimplePay_v21_Developer_Documentation_EN.pdf differ diff --git a/official-docs/hu/SimplePay_GooglePay_HU.pdf b/official-docs/hu/SimplePay_GooglePay_HU.pdf new file mode 100644 index 0000000..8957073 Binary files /dev/null and b/official-docs/hu/SimplePay_GooglePay_HU.pdf differ diff --git a/official-docs/hu/SimplePay_v21_Fejlesztoi_Dokumentacio_HU.pdf b/official-docs/hu/SimplePay_v21_Fejlesztoi_Dokumentacio_HU.pdf index e421c3c..514cbb7 100644 Binary files a/official-docs/hu/SimplePay_v21_Fejlesztoi_Dokumentacio_HU.pdf and b/official-docs/hu/SimplePay_v21_Fejlesztoi_Dokumentacio_HU.pdf differ diff --git a/public/logos/simplepay_vertical.svg b/public/logos/simplepay_vertical.svg new file mode 100644 index 0000000..7feb370 --- /dev/null +++ b/public/logos/simplepay_vertical.svg @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/boost/skills/simplepay-development/SKILL.md b/resources/boost/skills/simplepay-development/SKILL.md index ea3bf43..0a217ff 100644 --- a/resources/boost/skills/simplepay-development/SKILL.md +++ b/resources/boost/skills/simplepay-development/SKILL.md @@ -19,6 +19,8 @@ Use this skill when: - Storing cards for OneClick payments or recurring billing - Implementing auto (PCI-DSS) payments with direct card data - Implementing Request to Pay (RTP) bank transfer flows +- Accepting Google Pay with the Google Pay button on the merchant's own checkout page +- Sending customers to separate pages per payment outcome (`urls`) - Configuring the simplepay package (sandbox, multi-currency, logging) ## Package overview @@ -164,19 +166,21 @@ class HandleSuccessfulPayment The `GET /simplepay/back` route is auto-registered. Customize the response by implementing `BackUrlResponse`: ```php +use Illuminate\Http\Request; use Netipar\SimplePay\Contracts\BackUrlResponse; use Netipar\SimplePay\Dto\BackResponse; +use Symfony\Component\HttpFoundation\Response; class CustomBackResponse implements BackUrlResponse { - public function toResponse(BackResponse $response): mixed + public function toResponse(Request $request, BackResponse $back): Response { - // $response->event (BackEvent enum: SUCCESS, FAIL, CANCEL, TIMEOUT) - // $response->transactionId - // $response->orderRef + // $back->event (BackEvent enum: SUCCESS, FAIL, CANCEL, TIMEOUT, PENDING for Request to Pay) + // $back->transactionId + // $back->orderRef return redirect()->route('payment.result', [ - 'status' => $response->event->value, + 'status' => $back->event->value, ]); } } @@ -280,32 +284,38 @@ SimplePay::cardStorage()->tokenCancel(Currency::HUF, 'token'); ## Request to Pay (RTP) — bank transfer ```php +use Netipar\SimplePay\Dto\RedirectUrls; use Netipar\SimplePay\Dto\RtpStartRequest; use Netipar\SimplePay\Dto\RtpDoRequest; use Netipar\SimplePay\Dto\RtpPayment; -// Start RTP session -$request = new RtpStartRequest( +// Start a payment request; the customer approves it in their bank +$response = SimplePay::rtp()->start(new RtpStartRequest( currency: Currency::HUF, - customerEmail: 'customer@example.com', + total: 5000, orderRef: 'ORDER-005', - items: [new Item(title: 'Product', price: 5000, quantity: 1)], - url: route('payment.back'), -); -$response = SimplePay::rtp()->start($request); + customerEmail: 'customer@example.com', + url: route('simplepay.back'), + customer: 'Customer Name', + // Optional per-event URLs; RTP adds `pending` (bank account given, transfer not arrived yet) + urls: new RedirectUrls( + success: route('payment.success'), + fail: route('payment.failed'), + cancel: route('checkout'), + timeout: route('payment.timeout'), + pending: route('payment.pending'), + ), +)); +// $response->paymentUrl, $response->transactionId; $response->timeout is null for RTP -// Execute RTP payment -$doRequest = new RtpDoRequest( +// Batch payment requests without the customer being present (1–1000 per call) +$result = SimplePay::rtp()->do(new RtpDoRequest( currency: Currency::HUF, - transactionId: $response->transactionId, payments: [ - new RtpPayment( - bankCode: 'BANK_CODE', - accountNumber: 'HU12345678901234567890123456', - ), + new RtpPayment(orderRef: 'ORDER-006', total: 1000, customerEmail: 'a@example.com', customer: 'Customer A'), + new RtpPayment(orderRef: 'ORDER-007', total: 2000, customerEmail: 'b@example.com', customer: 'Customer B'), ], -); -$result = SimplePay::rtp()->do($doRequest); +)); // Query/refund/reverse RTP SimplePay::rtp()->query(Currency::HUF, transactionId: '12345'); @@ -313,6 +323,46 @@ SimplePay::rtp()->refund(Currency::HUF, transactionId: '12345', amount: 5000); SimplePay::rtp()->reverse(Currency::HUF, transactionId: '12345'); ``` +## Google Pay on the merchant's checkout page + +Google Pay also works on the SimplePay payment page with `PaymentMethod::CARD`. For the Google Pay button on the merchant's own checkout page: + +```php +use Netipar\SimplePay\Dto\GooglePayDoRequest; + +// 1. Checkout page: pass the button configuration to the frontend (Blade variable or Inertia prop) +$googlePay = SimplePay::payment()->googlePayConfig(Currency::HUF, $order->total); +// gpayMerchantId, gpayMerchantName, gpayEnvironment (TEST in sandbox), gateway ('simplepay'), +// gatewayMerchantId, paymentType ('CARD'), total, currency + +// 2. Backend endpoint: the frontend posts btoa(paymentMethodData.tokenizationData.token) +$validated = $request->validate(['googlePayToken' => ['required', 'string']]); + +$response = SimplePay::payment()->doGooglePay(new GooglePayDoRequest( + currency: Currency::HUF, + total: $order->total, + orderRef: $order->reference, + customerEmail: $order->email, + googlePayToken: $validated['googlePayToken'], + url: route('simplepay.back'), // return URL after a 3DS challenge, from server-side config + threeDSReqAuthMethod: '02', // 01 guest, 02 registered, 05 third-party login +)); + +return response()->json([ + 'transactionId' => $response->transactionId, + 'redirectUrl' => $response->redirectUrl, // set only when a 3DS challenge is required +]); +``` + +- A 3DS challenge (error code 3004) does not throw: `$response->requiresChallenge()` is true and the frontend navigates to `redirectUrl`. Other error codes throw `SimplePayApiException`. +- Without 3DS the customer stays on the checkout page and SimplePay sends no back redirect. +- Fulfil the order from the IPN or a `query()`, not from the synchronous response. +- Needs `SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID` and `SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME` (Google Pay & Wallet Console). Full JavaScript example: `examples/en/google-pay.md`. + +## Event-specific redirect URLs + +`PaymentRequest` and `RtpStartRequest` accept `urls: new RedirectUrls(success:, fail:, cancel:, timeout:, pending: null)`. SimplePay ignores `url` when `urls` is sent; `pending` is for Request to Pay only. Pages behind these URLs receive the signed `r`/`s` parameters: verify them with `SimplePay::payment()->handleBack($r, $s)`. + ## Available enums Always use enum cases instead of raw strings: @@ -320,7 +370,7 @@ Always use enum cases instead of raw strings: - `Currency`: `HUF`, `EUR`, `USD` - `PaymentMethod`: `CARD`, `WIRE`, `EAM` - `PaymentStatus`: `INIT`, `FINISHED`, `AUTHORIZED`, `NOTAUTHORIZED`, `INPAYMENT`, `CANCELLED`, `TIMEOUT`, `INFRAUD`, `FRAUD`, `REFUND`, `REVERSED` -- `BackEvent`: `SUCCESS`, `FAIL`, `CANCEL`, `TIMEOUT` +- `BackEvent`: `SUCCESS`, `FAIL`, `CANCEL`, `TIMEOUT`, `PENDING` (Request to Pay only; cover it in exhaustive `match` expressions) - `TransactionType`: `CIT` (Customer-Initiated), `MIT` (Merchant-Initiated), `REC` (Recurring) - `ErrorCode`: 350+ error codes with `description()` method returning Hungarian text @@ -336,6 +386,7 @@ try { $e->getErrorCodes(); // [5083] $e->hasErrorCode(5083); // true $e->getResolvedCodes(); // [ErrorCode::TokenTimesRequired] + $e->getResponseData(); // full API response, e.g. redirectUrl of a 3DS challenge (3004) } ``` @@ -353,11 +404,13 @@ SIMPLEPAY_BACK_URL=https://yourdomain.com/payment/back SIMPLEPAY_TIMEOUT=600 SIMPLEPAY_LOG_CHANNEL=simplepay SIMPLEPAY_AUTO_CHALLENGE=true +SIMPLEPAY_GOOGLE_PAY_MERCHANT_ID=google_pay_merchant_id +SIMPLEPAY_GOOGLE_PAY_MERCHANT_NAME="Shop name" ``` Multi-currency: configure separate merchant credentials per currency (HUF, EUR, USD). -Publish config with: `php artisan vendor:publish --tag=simplepay-config` +Publish config with: `php artisan vendor:publish --tag=simplepay-config` (or `--tag=simplepay` for config and logos). ## Logo and asset publishing @@ -419,7 +472,9 @@ 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 | +| `PaymentResponse` | Transaction ID, payment URL, timeout (null for RTP starts) | +| `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 | | `RefundRequest` / `RefundResponse` | Refund with remaining total | | `QueryResponse` | Full transaction status | diff --git a/src/Dto/GooglePayDoRequest.php b/src/Dto/GooglePayDoRequest.php new file mode 100644 index 0000000..47fc1fa --- /dev/null +++ b/src/Dto/GooglePayDoRequest.php @@ -0,0 +1,58 @@ + + * + * @throws RandomException + */ + public function toArray(): array + { + return array_filter([ + 'salt' => bin2hex(random_bytes(16)), + 'merchant' => '', + 'orderRef' => $this->orderRef, + 'currency' => $this->currency->value, + 'total' => $this->total, + 'customerEmail' => $this->customerEmail, + 'language' => $this->language, + 'googlePayToken' => $this->googlePayToken, + 'type' => $this->type->value, + 'threeDSReqAuthMethod' => $this->threeDSReqAuthMethod, + 'invoice' => $this->invoice?->toArray(), + 'customer' => $this->customer, + 'url' => $this->url, + 'timeout' => date('c', time() + ($this->timeout ?? (int) config('simplepay.timeout', 600))), + 'sdkVersion' => 'SimplePay_PHP_SDK_2.1_Laravel', + ], fn (mixed $value): bool => $value !== null); + } +} diff --git a/src/Dto/GooglePayResponse.php b/src/Dto/GooglePayResponse.php new file mode 100644 index 0000000..49adb47 --- /dev/null +++ b/src/Dto/GooglePayResponse.php @@ -0,0 +1,40 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + transactionId: (int) $data['transactionId'], + orderRef: (string) $data['orderRef'], + currency: isset($data['currency']) ? Currency::from($data['currency']) : null, + total: isset($data['total']) ? (float) $data['total'] : null, + redirectUrl: isset($data['redirectUrl']) ? (string) $data['redirectUrl'] : null, + ); + } + + /** + * Whether the card issuer asked for a 3DS challenge: send the customer to $redirectUrl. + */ + public function requiresChallenge(): bool + { + return $this->redirectUrl !== null; + } +} diff --git a/src/Dto/PaymentRequest.php b/src/Dto/PaymentRequest.php index e585229..b7b96f9 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 RedirectUrls|null $urls Event-specific redirect URLs; SimplePay ignores $url when they are sent. */ public function __construct( public Currency $currency, @@ -36,6 +37,7 @@ public function __construct( public ?float $shippingPrice = null, public ?float $discount = null, public ?string $customer = null, + public ?RedirectUrls $urls = null, ) {} /** @@ -107,6 +109,10 @@ public function toArray(): array $data['customer'] = $this->customer; } + if ($this->urls !== null) { + $data['urls'] = $this->urls->toArray(); + } + return $data; } } diff --git a/src/Dto/PaymentResponse.php b/src/Dto/PaymentResponse.php index 9c7f2cd..e8b9e04 100644 --- a/src/Dto/PaymentResponse.php +++ b/src/Dto/PaymentResponse.php @@ -6,10 +6,13 @@ readonly class PaymentResponse { + /** + * @param string|null $timeout Missing from Request to Pay start responses. + */ public function __construct( public int $transactionId, public string $paymentUrl, - public string $timeout, + public ?string $timeout = null, ) {} /** @@ -20,7 +23,7 @@ public static function fromArray(array $data): self return new self( transactionId: (int) $data['transactionId'], paymentUrl: $data['paymentUrl'], - timeout: $data['timeout'], + timeout: $data['timeout'] ?? null, ); } } diff --git a/src/Dto/RedirectUrls.php b/src/Dto/RedirectUrls.php new file mode 100644 index 0000000..23416a2 --- /dev/null +++ b/src/Dto/RedirectUrls.php @@ -0,0 +1,39 @@ + + */ + public function toArray(): array + { + $urls = [ + 'success' => $this->success, + 'fail' => $this->fail, + 'cancel' => $this->cancel, + 'timeout' => $this->timeout, + ]; + + if ($this->pending !== null) { + $urls['pending'] = $this->pending; + } + + return $urls; + } +} diff --git a/src/Dto/RtpStartRequest.php b/src/Dto/RtpStartRequest.php index 4fcc6f4..c95e20d 100644 --- a/src/Dto/RtpStartRequest.php +++ b/src/Dto/RtpStartRequest.php @@ -11,6 +11,7 @@ { /** * @param array|null $items + * @param RedirectUrls|null $urls Event-specific redirect URLs, including `pending`; SimplePay ignores $url when they are sent. */ public function __construct( public Currency $currency, @@ -22,6 +23,7 @@ public function __construct( public ?Address $invoice = null, public ?array $items = null, public ?int $timeout = null, + public ?RedirectUrls $urls = null, ) {} /** @@ -52,6 +54,10 @@ public function toArray(): array $data['items'] = array_map(fn (Item $item) => $item->toArray(), $this->items); } + if ($this->urls !== null) { + $data['urls'] = $this->urls->toArray(); + } + return $data; } } diff --git a/src/Enums/BackEvent.php b/src/Enums/BackEvent.php index e5658ee..329fac5 100644 --- a/src/Enums/BackEvent.php +++ b/src/Enums/BackEvent.php @@ -10,4 +10,9 @@ enum BackEvent: string case Fail = 'FAIL'; case Cancel = 'CANCEL'; case Timeout = 'TIMEOUT'; + + /** + * Request to Pay only: the customer gave the bank account number, the transfer has not arrived yet. + */ + case Pending = 'PENDING'; } diff --git a/src/Services/PaymentService.php b/src/Services/PaymentService.php index 6e874b3..eb2d594 100644 --- a/src/Services/PaymentService.php +++ b/src/Services/PaymentService.php @@ -11,6 +11,8 @@ use Netipar\SimplePay\Dto\EamStartRequest; use Netipar\SimplePay\Dto\FinishRequest; use Netipar\SimplePay\Dto\FinishResponse; +use Netipar\SimplePay\Dto\GooglePayDoRequest; +use Netipar\SimplePay\Dto\GooglePayResponse; use Netipar\SimplePay\Dto\IpnMessage; use Netipar\SimplePay\Dto\PaymentRequest; use Netipar\SimplePay\Dto\PaymentResponse; @@ -18,6 +20,8 @@ use Netipar\SimplePay\Dto\RefundRequest; use Netipar\SimplePay\Dto\RefundResponse; use Netipar\SimplePay\Enums\Currency; +use Netipar\SimplePay\Enums\ErrorCode; +use Netipar\SimplePay\Exceptions\SimplePayApiException; use Netipar\SimplePay\Support\Client; use Netipar\SimplePay\Support\MerchantResolver; use Netipar\SimplePay\Support\Signature; @@ -97,6 +101,59 @@ public function doApplePay(ApplePayDoRequest $request): array return $this->client->post('/v2/doapplepay', $payload, $secretKey); } + /** + * Charge a Google Pay token on the merchant's own checkout page. + * + * A 3DS challenge (error code 3004) is a normal outcome here: the response + * carries the redirectUrl where the customer has to complete it. + * + * @throws RandomException + * @throws ConnectionException + */ + public function doGooglePay(GooglePayDoRequest $request): GooglePayResponse + { + $merchant = $this->merchantResolver->getMerchant($request->currency); + $secretKey = $this->merchantResolver->getSecretKey($request->currency); + + $payload = $request->toArray(); + $payload['merchant'] = $merchant; + + try { + $response = $this->client->post('/v2/dogooglepay', $payload, $secretKey); + } catch (SimplePayApiException $exception) { + $response = $exception->getResponseData(); + + $isChallenge = $exception->hasErrorCode(ErrorCode::ThreeDsChallengeRedirect->value) + && is_string($response['redirectUrl'] ?? null) + && isset($response['transactionId'], $response['orderRef']); + + if (! $isChallenge) { + throw $exception; + } + } + + return GooglePayResponse::fromArray($response); + } + + /** + * The values the Google Pay button needs on the checkout page. + * + * @return array{gpayMerchantId: mixed, gpayMerchantName: mixed, gpayEnvironment: string, gateway: string, gatewayMerchantId: string, paymentType: string, total: string, currency: string} + */ + public function googlePayConfig(Currency $currency, float $total): array + { + return [ + 'gpayMerchantId' => config('simplepay.google_pay.merchant_id'), + 'gpayMerchantName' => config('simplepay.google_pay.merchant_name'), + 'gpayEnvironment' => $this->client->isSandbox() ? 'TEST' : 'PRODUCTION', + 'gateway' => 'simplepay', + 'gatewayMerchantId' => $this->merchantResolver->getMerchant($currency), + 'paymentType' => 'CARD', + 'total' => rtrim(rtrim(number_format($total, 2, '.', ''), '0'), '.'), + 'currency' => $currency->value, + ]; + } + public function handleBack(string $r, string $s): BackResponse { return new BackResponse($r, $s); diff --git a/src/Support/Client.php b/src/Support/Client.php index 9ec0df8..2906f40 100644 --- a/src/Support/Client.php +++ b/src/Support/Client.php @@ -18,6 +18,7 @@ class Client * @return array * * @throws ConnectionException + * @throws SimplePayApiException */ public function post(string $endpoint, array $data, string $merchantKey): array { @@ -45,6 +46,7 @@ public function post(string $endpoint, array $data, string $merchantKey): array * @return array * * @throws ConnectionException + * @throws SimplePayApiException */ public function postSecurePay(string $endpoint, array $data, string $merchantKey): array { diff --git a/tests/Pest.php b/tests/Pest.php index 28b3657..ff0a2fe 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,22 @@ declare(strict_types=1); +use GuzzleHttp\Promise\PromiseInterface; +use Illuminate\Support\Facades\Http; +use Netipar\SimplePay\Support\Signature; use Netipar\SimplePay\Tests\TestCase; uses(TestCase::class)->in(__DIR__); + +/** + * @param array $data + */ +function fakeSimplePayResponse(array $data, string $secretKey): PromiseInterface +{ + $json = json_encode($data); + $signature = Signature::generate($secretKey, $json); + + return Http::response($data, 200, [ + 'Signature' => $signature, + ]); +} diff --git a/tests/Unit/BackResponseTest.php b/tests/Unit/BackResponseTest.php index f6fe24f..47f6cb8 100644 --- a/tests/Unit/BackResponseTest.php +++ b/tests/Unit/BackResponseTest.php @@ -25,6 +25,20 @@ expect($response->merchant)->toBe('TEST_HUF_MERCHANT'); }); +it('parses a pending back response of a Request to Pay', function () { + $data = json_encode([ + 'e' => 'PENDING', + 't' => 502272810, + 'o' => 'RTP-001', + 'm' => 'TEST_HUF_MERCHANT', + ]); + + $response = new BackResponse(base64_encode($data), Signature::generate('TEST_HUF_SECRET_KEY', $data)); + + expect($response->event)->toBe(BackEvent::Pending) + ->and($response->transactionId)->toBe(502272810); +}); + it('throws on invalid base64', function () { new BackResponse('not-valid-base64!!!', 'fake-signature'); })->throws(RuntimeException::class); diff --git a/tests/Unit/GooglePayTest.php b/tests/Unit/GooglePayTest.php new file mode 100644 index 0000000..4481b9f --- /dev/null +++ b/tests/Unit/GooglePayTest.php @@ -0,0 +1,149 @@ + fakeSimplePayResponse([ + 'transactionId' => 10312754, + 'orderRef' => 'ORD-GPAY-001', + 'currency' => 'HUF', + 'total' => 1500.0, + 'merchant' => 'TEST_HUF_MERCHANT', + ], 'TEST_HUF_SECRET_KEY'), + ]); + + $response = app(PaymentService::class)->doGooglePay(googlePayRequest()); + + expect($response->transactionId)->toBe(10312754) + ->and($response->orderRef)->toBe('ORD-GPAY-001') + ->and($response->currency)->toBe(Currency::HUF) + ->and($response->total)->toBe(1500.0) + ->and($response->requiresChallenge())->toBeFalse() + ->and($response->redirectUrl)->toBeNull(); + + Http::assertSent(function ($request) { + $body = json_decode($request->body(), true); + + return str_ends_with($request->url(), '/payment/v2/dogooglepay') + && $body['merchant'] === 'TEST_HUF_MERCHANT' + && $body['googlePayToken'] === base64_encode('{"signature":"test"}') + && $body['currency'] === 'HUF' + && (float) $body['total'] === 1500.0 + && $body['orderRef'] === 'ORD-GPAY-001' + && $body['customerEmail'] === 'test@example.com' + && $body['language'] === 'HU' + && $body['type'] === 'CIT' + && $body['threeDSReqAuthMethod'] === '02' + && $body['url'] === 'https://example.com/simplepay/back' + && $body['invoice']['city'] === 'Budapest' + && $body['sdkVersion'] === 'SimplePay_PHP_SDK_2.1_Laravel' + && isset($body['salt'], $body['timeout']); + }); +}); + +it('returns the 3DS challenge redirect instead of throwing', function () { + Http::fake([ + 'sandbox.simplepay.hu/payment/v2/dogooglepay' => fakeSimplePayResponse([ + 'errorCodes' => [3004], + 'transactionId' => 10312754, + 'orderRef' => 'ORD-GPAY-001', + 'currency' => 'HUF', + 'total' => 1500.0, + 'redirectUrl' => 'https://sb-checkout.simplepay.hu/pay/pay/pspHU/challenge', + ], 'TEST_HUF_SECRET_KEY'), + ]); + + $response = app(PaymentService::class)->doGooglePay(googlePayRequest()); + + expect($response->requiresChallenge())->toBeTrue() + ->and($response->redirectUrl)->toBe('https://sb-checkout.simplepay.hu/pay/pay/pspHU/challenge') + ->and($response->transactionId)->toBe(10312754); +}); + +it('throws when a 3004 response has no redirect url', function () { + Http::fake([ + 'sandbox.simplepay.hu/payment/v2/dogooglepay' => fakeSimplePayResponse([ + 'errorCodes' => [3004], + 'transactionId' => 10312754, + 'orderRef' => 'ORD-GPAY-001', + ], 'TEST_HUF_SECRET_KEY'), + ]); + + app(PaymentService::class)->doGooglePay(googlePayRequest()); +})->throws(SimplePayApiException::class); + +it('throws when a 3004 response has no transaction id', function () { + Http::fake([ + 'sandbox.simplepay.hu/payment/v2/dogooglepay' => fakeSimplePayResponse([ + 'errorCodes' => [3004], + 'redirectUrl' => 'https://sb-checkout.simplepay.hu/pay/pay/pspHU/challenge', + ], 'TEST_HUF_SECRET_KEY'), + ]); + + app(PaymentService::class)->doGooglePay(googlePayRequest()); +})->throws(SimplePayApiException::class); + +it('throws for other error codes', function () { + Http::fake([ + 'sandbox.simplepay.hu/payment/v2/dogooglepay' => fakeSimplePayResponse([ + 'errorCodes' => [5321], + 'orderRef' => 'ORD-GPAY-001', + ], 'TEST_HUF_SECRET_KEY'), + ]); + + app(PaymentService::class)->doGooglePay(googlePayRequest()); +})->throws(SimplePayApiException::class); + +it('builds the Google Pay button config for the sandbox', function () { + config()->set('simplepay.google_pay.merchant_id', 'BCR2DN4TEST'); + config()->set('simplepay.google_pay.merchant_name', 'Test Shop'); + + expect(app(PaymentService::class)->googlePayConfig(Currency::EUR, 12.5))->toBe([ + 'gpayMerchantId' => 'BCR2DN4TEST', + 'gpayMerchantName' => 'Test Shop', + 'gpayEnvironment' => 'TEST', + 'gateway' => 'simplepay', + 'gatewayMerchantId' => 'TEST_EUR_MERCHANT', + 'paymentType' => 'CARD', + 'total' => '12.5', + 'currency' => 'EUR', + ]); +}); + +it('uses the production Google Pay environment outside the sandbox', function () { + config()->set('simplepay.sandbox', false); + + $config = app(PaymentService::class)->googlePayConfig(Currency::HUF, 2500); + + expect($config['gpayEnvironment'])->toBe('PRODUCTION') + ->and($config['gatewayMerchantId'])->toBe('TEST_HUF_MERCHANT') + ->and($config['total'])->toBe('2500'); +}); diff --git a/tests/Unit/PaymentResponseTest.php b/tests/Unit/PaymentResponseTest.php index 406d673..cdb4cfc 100644 --- a/tests/Unit/PaymentResponseTest.php +++ b/tests/Unit/PaymentResponseTest.php @@ -13,3 +13,17 @@ expect($response->transactionId)->toBe(502272810); }); + +it('accepts a Request to Pay start response, which has no timeout', function () { + $response = PaymentResponse::fromArray([ + 'orderRef' => 'RTP-001', + 'transactionId' => 502272810, + 'paymentUrl' => 'https://sandbox.simplepay.hu/pay/rtp/pspHU/abc', + 'currency' => 'HUF', + 'total' => 25.0, + 'merchant' => 'TEST_HUF_MERCHANT', + ]); + + expect($response->transactionId)->toBe(502272810) + ->and($response->timeout)->toBeNull(); +}); diff --git a/tests/Unit/PaymentServiceTest.php b/tests/Unit/PaymentServiceTest.php index 4a24f17..78da6a5 100644 --- a/tests/Unit/PaymentServiceTest.php +++ b/tests/Unit/PaymentServiceTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Support\Facades\Http; use Netipar\SimplePay\Dto\FinishRequest; use Netipar\SimplePay\Dto\PaymentRequest; +use Netipar\SimplePay\Dto\RedirectUrls; use Netipar\SimplePay\Dto\RefundRequest; use Netipar\SimplePay\Enums\Currency; use Netipar\SimplePay\Enums\PaymentMethod; @@ -13,16 +13,6 @@ use Netipar\SimplePay\Services\PaymentService; use Netipar\SimplePay\Support\Signature; -function fakeSimplePayResponse(array $data, string $secretKey): PromiseInterface -{ - $json = json_encode($data); - $signature = Signature::generate($secretKey, $json); - - return Http::response($data, 200, [ - 'Signature' => $signature, - ]); -} - it('sends a start request and returns PaymentResponse', function () { $secretKey = 'TEST_HUF_SECRET_KEY'; @@ -295,6 +285,31 @@ function fakeSimplePayResponse(array $data, string $secretKey): PromiseInterface expect($data)->not->toHaveKey('invoice'); expect($data)->not->toHaveKey('delivery'); expect($data)->not->toHaveKey('items'); + expect($data)->not->toHaveKey('urls'); +}); + +it('builds PaymentRequest payload with event-specific redirect urls', function () { + $request = new PaymentRequest( + currency: Currency::HUF, + total: 1500.0, + orderRef: 'ORD-URLS', + customerEmail: 'test@example.com', + language: 'HU', + url: 'https://example.com/back', + urls: new RedirectUrls( + success: 'https://example.com/payment/success', + fail: 'https://example.com/payment/fail', + cancel: 'https://example.com/payment/cancel', + timeout: 'https://example.com/payment/timeout', + ), + ); + + expect($request->toArray()['urls'])->toBe([ + 'success' => 'https://example.com/payment/success', + 'fail' => 'https://example.com/payment/fail', + 'cancel' => 'https://example.com/payment/cancel', + 'timeout' => 'https://example.com/payment/timeout', + ]); }); it('throws SimplePayApiException when API returns errorCodes', function () { diff --git a/tests/Unit/RtpServiceTest.php b/tests/Unit/RtpServiceTest.php index 0f5cb5b..d507bdb 100644 --- a/tests/Unit/RtpServiceTest.php +++ b/tests/Unit/RtpServiceTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Http; use Netipar\SimplePay\Dto\PaymentResponse; +use Netipar\SimplePay\Dto\RedirectUrls; use Netipar\SimplePay\Dto\RtpDoRequest; use Netipar\SimplePay\Dto\RtpPayment; use Netipar\SimplePay\Dto\RtpStartRequest; @@ -66,6 +67,35 @@ }); }); +it('sends event-specific redirect urls with the pending url', function () { + $this->rtpService->start(new RtpStartRequest( + currency: Currency::HUF, + total: 1000.0, + orderRef: 'ORDER-URLS', + customerEmail: 'test@example.com', + url: 'https://example.com/rtp/back', + customer: 'Test Customer', + urls: new RedirectUrls( + success: 'https://example.com/rtp/success', + fail: 'https://example.com/rtp/fail', + cancel: 'https://example.com/rtp/cancel', + timeout: 'https://example.com/rtp/timeout', + pending: 'https://example.com/rtp/pending', + ), + )); + + Http::assertSent(function ($httpRequest) { + return str_contains($httpRequest->url(), '/rtp/start') + && $httpRequest['urls'] === [ + 'success' => 'https://example.com/rtp/success', + 'fail' => 'https://example.com/rtp/fail', + 'cancel' => 'https://example.com/rtp/cancel', + 'timeout' => 'https://example.com/rtp/timeout', + 'pending' => 'https://example.com/rtp/pending', + ]; + }); +}); + it('sends do request to /rtp/do with payments array', function () { $request = new RtpDoRequest( currency: Currency::HUF,