From fe181a08081d50402b9bc987422546654abae970 Mon Sep 17 00:00:00 2001 From: ramazanayyildiz <48105049+ramazanayyildiz@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:57:22 +0300 Subject: [PATCH 1/7] feat(revolut): add revolut payment gateway integration - Add revolut config to payable.php - Implement RevolutService with widget flow support - Update PaymentTestController with revolut test method --- config/payable.php | 14 + .../Controllers/RamazanTestController.php | 101 ++++-- src/Services/RevolutService.php | 328 ++++++++++++++++++ 3 files changed, 406 insertions(+), 37 deletions(-) create mode 100644 src/Services/RevolutService.php diff --git a/config/payable.php b/config/payable.php index d6c67d0..4efeb34 100644 --- a/config/payable.php +++ b/config/payable.php @@ -132,5 +132,19 @@ 'secret_key' => env('BUCKAROO_LIVE_SECRET_KEY', ''), ], ], + 'revolut' => [ + 'mode' => env('REVOLUT_MODE', 'sandbox'), + 'api_version' => env('REVOLUT_API_VERSION', '2024-05-01'), + 'sandbox' => [ + 'api_url' => env('REVOLUT_SANDBOX_API_URL', ''), + 'public_key' => env('REVOLUT_SANDBOX_PUBLIC_KEY', ''), + 'secret_key' => env('REVOLUT_SANDBOX_SECRET_KEY', ''), + ], + 'live' => [ + 'api_url' => env('REVOLUT_LIVE_API_URL', ''), + 'public_key' => env('REVOLUT_LIVE_PUBLIC_KEY', ''), + 'secret_key' => env('REVOLUT_LIVE_SECRET_KEY', ''), + ], + ], ], ]; diff --git a/src/Http/Controllers/RamazanTestController.php b/src/Http/Controllers/RamazanTestController.php index c44c14e..8798bdd 100644 --- a/src/Http/Controllers/RamazanTestController.php +++ b/src/Http/Controllers/RamazanTestController.php @@ -1,45 +1,44 @@ 1000, + 'amount' => 1, 'installment' => 1, 'currency' => 'TRY', 'locale' => 'tr', - 'order_id' => 'TEST-'.uniqid(), - 'card_name' => 'John Doe', + 'order_id' => 'TEST-' . uniqid(), + 'card_name' => 'Ramazan Ayyildiz', + 'card_no' => '4912055018403926', 'company_name' => 'B2Press', - 'card_no' => '9792290735587016', - 'card_month' => '02', - 'card_year' => '2026', - 'card_cvv' => '790', + 'card_month' => '03', + 'card_year' => '2029', + 'card_cvv' => '659', 'user_email' => 'test@example.com', 'user_ip' => request()->ip(), ]; - + return $payable->pay($params); } public function testTebCommon() { $payable = new Payable('teb-common-pos'); - + $params = [ 'paid_price' => 1, 'installment' => 1, 'currency' => 'TRY', 'locale' => 'tr', - 'order_id' => 'TEST-'.uniqid(), + 'order_id' => 'TEST-' . uniqid(), 'card_name' => 'Ramazan Ayyildiz', 'card_no' => '4912055018403926', 'company_name' => 'B2Press', @@ -49,39 +48,39 @@ public function testTebCommon() 'user_email' => 'test@example.com', 'user_ip' => request()->ip(), ]; - + return $payable->pay($params); } public function testPaypal() { $payable = new Payable('paypal'); - + $params = [ 'paid_price' => 100.10, 'installment' => 1, 'currency' => 'USD', - 'order_id' => 'TEST-'.uniqid(), + 'order_id' => 'TEST-' . uniqid(), 'user_email' => 'test@example.com', 'user_name' => 'John', 'user_surname' => 'Doe', 'user_ip' => request()->ip(), 'company_name' => 'B2Press', - 'locale' => 'tr-TR', + 'locale' => 'tr-TR' ]; - + return $payable->pay($params); } public function testPaypalRefund() { $payable = new Payable('paypal'); - + $params = [ /* 'capture_id' => '5JC53758C94870609', */ - 'payment_id' => 17, + 'payment_id' =>17, ]; - + $response = $payable->refund($params); dd($response); } @@ -89,12 +88,12 @@ public function testPaypalRefund() public function testPaypalCancel() { $payable = new Payable('paypal'); - + $params = [ 'authorization_id' => ' ', 'payment_id' => 7, ]; - + $response = $payable->cancel($params); dd($response); } @@ -102,57 +101,85 @@ public function testPaypalCancel() public function testIdeal() { $payable = new Payable('ideal'); - + $params = [ 'paid_price' => 100.10, 'installment' => 1, - 'currency' => 'USD', + 'currency' => 'EUR', 'issuer' => 'ABNANL2A', - 'order_id' => 'TEST-'.uniqid(), + 'order_id' => 'TEST-' . uniqid(), 'user_email' => 'test@example.com', 'user_ip' => request()->ip(), ]; - + return $payable->pay($params); } public function testIdealQr() { $payable = new Payable('ideal-qr'); - + $params = [ 'paid_price' => 100.10, 'installment' => 1, 'currency' => 'USD', 'issuer' => 'ABNANL2A', - 'order_id' => 'TEST'.uniqid(), + 'order_id' => 'TEST' . uniqid(), 'user_email' => 'test@example.com', - 'description' => 'Test purchase', + 'description' => 'Test Payment', 'user_ip' => request()->ip(), ]; - - $qrimageurl = $payable->pay($params); - + + $qrimageurl= $payable->pay($params); return ''; } public function testIdealRefund() { $payable = new Payable('ideal'); - + $params = [ /* 'order_id' => 'TEST-6814ef7229c4b', 'transaction_key' => '89819DD62261406A942D1FA029835852', 'paid_price' => 100.10, */ 'payment_id' => 16, ]; - + $response = $payable->refund($params); dd($response); } + public function handleResponse(Request $request) { dd($request->all()); } -} + + public function testRevolut() + { + $payable = new Payable('revolut'); + + $params = [ + // Use either 'paid_price' (major units) or 'amount' (minor units) + 'amount' => 10.00, + 'currency' => 'EUR', + 'order_id' => 'ORDER-'.uniqid(), + 'user_email' => 'john@example.com', + 'user_id' => 1, + 'installment' => 1, + 'user_ip' => request()->ip(), + 'description' => 'Test Revolut payment', + ]; + + $result = $payable->pay($params); + + if (($result['type'] ?? null) === 'widget') { + return view('checkout.revolut', [ + 'token' => $result['token'] ?? '', + 'env' => $result['env'] ?? 'sandbox', + ]); + } + + abort(400, $result['message'] ?? 'Unable to initialize Revolut order'); + } +} \ No newline at end of file diff --git a/src/Services/RevolutService.php b/src/Services/RevolutService.php new file mode 100644 index 0000000..091220a --- /dev/null +++ b/src/Services/RevolutService.php @@ -0,0 +1,328 @@ +setConfig(); + } + + /** + * Load config + * + */ + public function setConfig() + { + $this->config = config($this->getConfigName()); // e.g. 'payable.revolut' + $this->setMode($this->config['mode'] ?? 'sandbox'); + + $modeCfg = $this->config[$this->mode] ?? []; + $this->url = rtrim($modeCfg['api_url'] ?? '', '/'); + $this->secretKey = $modeCfg['secret_key'] ?? null; + $this->publicKey = $modeCfg['public_key'] ?? null; // not required for this flow + $this->apiVersion = $this->config['api_version'] ?? null; + + $this->headers = [ + 'Authorization' => 'Bearer '.$this->secretKey, + 'Revolut-Api-Version' => $this->apiVersion, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + $this->setHeaders($this->headers); + + return $this; + } + + /** Minimal validation for widget flow */ + public function validateParams($params) + { + $required = ['order_id', 'currency']; + $missing = array_diff($required, array_keys($params)); + if (!empty($missing)) { + return 'These keys are missing for Revolut: '.implode(', ', $missing); + } + return true; + } + + /** Convert to minor units */ + protected function normalizeAmountMinor(array $params): int + { + if (isset($params['amount'])) return (int) $params['amount']; // minor + if (isset($params['price'])) return (int) $params['price']; // minor (your package) + if (isset($params['paid_price'])) return (int) round(((float) $params['paid_price']) * 100); // major→minor + return 0; + } + + /** Build Create Order payload */ + public function hydrateParams(array $params): array + { + $amountMinor = $this->normalizeAmountMinor($params); + + return [ + 'amount' => $amountMinor, + 'currency' => $params['currency'] ?? 'EUR', + 'merchant_order_ext_ref' => $params['order_id'], + 'merchant_customer_ext_ref' => $params['user_id'] ?? null, + 'description' => $params['description'] ?? ('Payment for order '.$params['order_id']), + 'customer' => [ + 'email' => $params['user_email'] ?? null, + ], + 'metadata' => [ + 'order_id' => $params['order_id'], + 'user_email' => $params['user_email'] ?? null, + 'ip' => $params['user_ip'] ?? null, + ], + // Optional: 'capture_mode' => 'automatic' | 'manual' + ]; + } + + /** POST /orders */ + protected function createOrder(array $payload): array + { + $resp = $this->postReq( + $this->url, + '/orders', + $payload, + $this->headers, + 'json', + $this->mode + ); + + return is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; + } + + /** GET /orders/{id} (optional UX or webhook confirm) */ + protected function retrieveOrder(string $orderId): array + { + $resp = $this->getReq( + $this->url, + '/orders/'.urlencode($orderId), + [], + $this->headers + ); + return is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; + } + + /** + * Main entry for Card Field / Card Pop-up. + * Creates the order and returns data your JS embed will use. + */ + public function createWidgetOrder(array $params): array + { + $validated = $this->validateParams($params); + if ($validated !== true) return ['error' => $validated]; + + $payload = $this->hydrateParams($params); + $order = $this->createOrder($payload); + + // Persist PENDING with raw provider response + $this->payment?->update([ + 'status' => $this->getStatusEnum()::PENDING, + 'response' => $order, + ]); + + return [ + 'token' => $order['token'] ?? null, // REQUIRED by RevolutCheckout() + 'order_id' => $order['id'] ?? null, + 'env' => $this->mode === 'production' ? 'production' : 'sandbox', + 'amount' => $payload['amount'] ?? null, + 'currency' => $payload['currency'] ?? null, + 'email' => $payload['customer']['email'] ?? null, + ]; + } + + /** + * Optional: keep pay() compatible — just return directive for widget flow. + * Your controller/UI should call createWidgetOrder() and then run the JS. + */ + public function pay(array $params) + { + $data = $this->createWidgetOrder($params); + if (!empty($data['token'])) { + return [ + 'type' => 'widget', + 'order_id' => $data['order_id'], + 'token' => $data['token'], + 'env' => $data['env'], + 'message' => 'Use Card Field or Pop-up with RevolutCheckout(token, env).', + ]; + } + return ['type' => 'error', 'message' => $data['error'] ?? 'Unable to initialize Revolut order']; + } + + /** Return URL — UX only; don’t finalize here */ + public function handleResponse(HttpRequest $request) + { + $q = $request->query(); + + $this->payment?->update([ + 'status' => $this->getStatusEnum()::PENDING, + 'response' => $q, + ]); + + $orderId = $q['order_id'] ?? null; + $order = $orderId ? $this->retrieveOrder($orderId) : null; + + $payload = [ + 'id' => $q['payment_id'] ?? ($this->payment->id ?? null), + 'status' => self::RESPONSE_STATUS_SUCCESS, // means "return handled" + 'payment_service' => $q['payment_service'] ?? $this->service, + 'token' => $q['token'] ?? '', + 'order_id' => $order['id'] ?? $orderId, + 'order_data' => $order ?: $q, + ]; + + return $this->generatePostForm($payload, $this->payableReturnUrl); + } + + /** Webhook — source of truth (COMPLETED/FAILED/CANCELLED/AUTHORISED) */ + public function handleWebhook(HttpRequest $request) + { + $payload = $request->all(); + + $orderId = $payload['order']['id'] + ?? $payload['order_id'] + ?? $payload['merchant_order_ext_ref'] + ?? null; + + $rawState = $payload['order']['state'] + ?? $payload['state'] + ?? $payload['status'] + ?? null; + + if (!$orderId) { + return response()->json(['status' => 'ignored', 'reason' => 'missing order id'], 202); + } + + // Optional: re-fetch to confirm latest state + $order = $this->retrieveOrder($orderId); + $finalState = strtoupper($order['state'] ?? ($rawState ? strtoupper($rawState) : 'FAILED')); + + $payment = $this->payment ?: $this->findPaymentByOrderId($orderId); + if ($payment) { + $payment->update([ + 'status' => $this->mapRevolutStatus($finalState), + 'response' => $payload, + ]); + } + + return response()->json(['ok' => true]); + } + + /** POST /orders/{id}/cancel (void before capture) */ + public function cancel(array $params) + { + $orderId = $params['order_id'] ?? $this->payment->order_id ?? null; + if (!$orderId) return ['status' => 'error', 'message' => 'Order ID is required']; + + $resp = $this->postReq($this->url, '/orders/'.urlencode($orderId).'/cancel', (object)[], $this->headers, 'json', $this->mode); + $data = is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; + + $status = self::RESPONSE_STATUS_ERROR; + if (!isset($data['error'])) { + $this->payment?->update([ + 'status' => $this->getStatusEnum()::CANCELLED, + 'response' => $data, + ]); + $status = self::RESPONSE_STATUS_SUCCESS; + } + + return [ + 'type' => 'cancel', + 'status' => $status, + 'id' => $this->payment->id ?? ($params['payment_id'] ?? null), + 'payment_service' => $this->service, + 'order_data' => json_encode($data), + ]; + } + + /** POST /orders/{id}/refund (full/partial; amount in minor units) */ + public function refund(array|object $params) + { + $refundRequest = $this->validateRefundRequest($params); + if (!$refundRequest['validated']) return $refundRequest; + + $params = (array) $params; + $payment = $refundRequest['payment'] ?? null; + $orderId = $params['order_id'] ?? ($payment->order_id ?? null); + + if (!$orderId) { + return array_merge($refundRequest, ['message' => 'order_id is required for refund']); + } + + $payload = []; + if (!empty($params['amount'])) $payload['amount'] = (int) $params['amount']; // minor + + $resp = $this->postReq($this->url, '/orders/'.urlencode($orderId).'/refund', $payload, $this->headers, 'json', $this->mode); + $data = is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; + + $status = self::RESPONSE_STATUS_ERROR; + $message = 'Refund failed'; + + if (!isset($data['error'])) { + $payment?->update([ + 'status' => $this->getStatusEnum()::REFUNDED, + 'response' => $data, + ]); + $status = self::RESPONSE_STATUS_SUCCESS; + $message = 'Refunded successfully'; + } + + return array_merge($refundRequest, [ + 'status' => $status, + 'order_data' => json_encode($data), + 'message' => $message, + ]); + } + + /** Map Revolut states to your enum */ + protected function mapRevolutStatus(string $revolutStatus): string + { + $statusMap = [ + 'PENDING' => $this->getStatusEnum()::PENDING, + 'AUTHORISED' => $this->getStatusEnum()::PENDING, // wait for capture + 'COMPLETED' => $this->getStatusEnum()::COMPLETED, + 'FAILED' => $this->getStatusEnum()::FAILED, + 'CANCELLED' => $this->getStatusEnum()::CANCELLED, + ]; + return $statusMap[$revolutStatus] ?? $this->getStatusEnum()::FAILED; + } + + /** Helper: find Payment by order id for this gateway */ + protected function findPaymentByOrderId($orderId) + { + if (!$orderId) return null; + $paymentModel = config('payable.model'); + if (!$paymentModel) return null; + + return $paymentModel::where('order_id', $orderId) + ->where('payment_gateway', $this->service) + ->latest('id') + ->first(); + } + + /** Keep parent implementation */ + public function generatePostForm($params, $actionUrl = null) + { + return parent::generatePostForm($params, $actionUrl); + } +} \ No newline at end of file From 1040a0dea19d56f80f2c254a66385ba7f5e1ef2c Mon Sep 17 00:00:00 2001 From: ramazanayyildiz <48105049+ramazanayyildiz@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:09:22 +0300 Subject: [PATCH 2/7] docs: add revolut payment integration guide Add comprehensive documentation for implementing Revolut payments including backend controller, Vue component, Blade template, and JavaScript setup. The guide covers installation, configuration, and usage examples for both frontend and backend implementations. --- REVOLUT_IMPLEMENTATION.md | 338 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 REVOLUT_IMPLEMENTATION.md diff --git a/REVOLUT_IMPLEMENTATION.md b/REVOLUT_IMPLEMENTATION.md new file mode 100644 index 0000000..d25547a --- /dev/null +++ b/REVOLUT_IMPLEMENTATION.md @@ -0,0 +1,338 @@ +# Revolut Payment Integration Guide + +This guide explains how to implement Revolut payments using the unusualify/payable package. + +## Prerequisites + +1. Revolut Merchant Account +2. API credentials from Revolut dashboard +3. `unusualify/payable` package installed +4. `@revolut/checkout` npm package for frontend + +## Installation + +1. Install the npm package: +```bash +npm install @revolut/checkout +``` + +## Backend Implementation + +### 1. Controller Method + +Create a controller method to handle the payment initialization: + +```php +public function testRevolut() +{ + $payable = new Payable('revolut'); + + $params = [ + 'amount' => 10.00, // Amount in major units (e.g., 10.00 EUR) + 'currency' => 'EUR', // Currency code (e.g., EUR, USD, GBP) + 'order_id' => 'ORDER-'.uniqid(), // Unique order ID + 'user_email' => 'customer@example.com', + 'user_id' => 1, + 'installment' => 1, // Number of installments (1 for one-time payment) + 'user_ip' => request()->ip(), + 'description' => 'Order description', + ]; + + $result = $payable->pay($params); + + if (($result['type'] ?? null) === 'widget') { + return view('checkout.revolut', [ + 'token' => $result['token'] ?? '', + 'env' => $result['env'] ?? 'sandbox', + ]); + } + + abort(400, $result['message'] ?? 'Unable to initialize Revolut order'); +} +``` + +## Frontend Implementation + +### 1. Vue Component Implementation + +Create a new Vue component at `resources/js/Components/RevolutCheckout.vue`: + +```vue + + + + + +``` + +#### Usage in your Vue application: + +1. First, import and register the component: + +```javascript +// In your app.js or main.js +import RevolutCheckout from './Components/RevolutCheckout.vue'; + +// If using Vue 3 +app.component('RevolutCheckout', RevolutCheckout); + +// If using Vue 2 +// Vue.component('RevolutCheckout', RevolutCheckout); +``` + +2. Use the component in your view: + +```vue + + + +``` + +### 2. Blade Template (Alternative to Vue) (`resources/views/checkout/revolut.blade.php`) + +```html + + + + + + Revolut Checkout + + + +
+

Revolut Checkout

+ + + + + +
+
+ +
+
+ + + + + @vite(['resources/js/revolut.js']) + + +``` + +### 2. JavaScript (`resources/js/revolut.js`) + +```javascript +import RevolutCheckout from '@revolut/checkout'; + +document.addEventListener('DOMContentLoaded', async () => { + const token = document.querySelector('input[name="token"]').value; + const env = document.querySelector('input[name="env"]').value; + + if (!token) { + console.error('Revolut token missing.'); + return; + } + + const loader = document.getElementById('revolut-loader'); + const showLoader = () => { loader.style.display = 'flex'; }; + const hideLoader = () => { loader.style.display = 'none'; }; + + const setButtonBusy = (btn, busy) => { + if (!btn) return; + if (busy) { + btn.disabled = true; + if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent; + btn.textContent = 'Processing...'; + btn.setAttribute('aria-busy', 'true'); + } else { + btn.disabled = false; + btn.textContent = btn.dataset.originalText || 'Pay'; + btn.removeAttribute('aria-busy'); + } + }; + + try { + const instance = await RevolutCheckout(token, env); + const cardTarget = document.getElementById('card-field'); + + if (cardTarget) { + const submitBtn = document.getElementById('button-submit'); + + const cardField = instance.createCardField({ + target: cardTarget, + onSuccess() { + hideLoader(); + setButtonBusy(submitBtn, false); + // Handle successful payment (e.g., redirect to success page) + window.location.href = '/payment/success'; + }, + onError(error) { + hideLoader(); + setButtonBusy(submitBtn, false); + alert(`Payment failed: ${error.message || 'Unknown error'}`); + }, + }); + + if (submitBtn) { + submitBtn.addEventListener('click', () => { + const nameInput = document.querySelector('input[name="cardholder-name"]'); + const cardholderName = (nameInput && nameInput.value) ? nameInput.value : ''; + const meta = { name: cardholderName, cardholderName }; + + showLoader(); + setButtonBusy(submitBtn, true); + cardField.submit(meta); + }); + } + } + } catch (e) { + console.error('Failed to initialize Revolut', e); + alert('Failed to initialize payment: ' + (e?.message || e)); + } +}); +``` + +## Configuration + +Make sure to set up your Revolut API credentials in your `.env` file: + +```env +REVOLUT_ENV=sandbox # or 'production' for live +REVOLUT_PUBLIC_KEY=your_public_key +REVOLUT_SECRET_KEY=your_secret_key +``` From eecfb6cf5172861ce9b803a71d75312ce30cc883 Mon Sep 17 00:00:00 2001 From: OoBook Date: Mon, 1 Sep 2025 14:31:45 +0300 Subject: [PATCH 3/7] fix(URequest): enhance error handling for GuzzleHttp exceptions - Improved exception handling in URequest class to return structured error responses for specific HTTP status codes (422, 400, 404). - Added additional logging for debugging purposes. --- src/Services/URequest.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Services/URequest.php b/src/Services/URequest.php index 8b4d040..73938b3 100755 --- a/src/Services/URequest.php +++ b/src/Services/URequest.php @@ -91,6 +91,7 @@ public function postReq($url, $endPoint, $postFields, $headers, $type, $mode = n try { if ($type == 'json') { // dd($this->client); + if (count($headers) < 1) { $headers = [ 'Content-Type' => 'application/json', @@ -106,6 +107,7 @@ public function postReq($url, $endPoint, $postFields, $headers, $type, $mode = n // ); } // dd($postFields, "{$url}{$endPoint}", $this->headers); + $res = $this->client->post("{$url}{$endPoint}", [ 'headers' => $headers, 'json' => $postFields, @@ -170,7 +172,32 @@ public function postReq($url, $endPoint, $postFields, $headers, $type, $mode = n // dd($safeData); return $safeData; } catch (\Exception $e) { - return response()->json(['error' => $e->getMessage()], 500); + if($e instanceof \GuzzleHttp\Exception\ClientException) { + if($e->getResponse()->getStatusCode() == 422) { + return [ + 'status' => 'error', + 'code' => $e->getResponse()->getStatusCode(), + 'message' => $e->getResponse()->getBody()->getContents(), + ]; + } else if($e->getResponse()->getStatusCode() == 400) { + return [ + 'status' => 'error', + 'code' => $e->getResponse()->getStatusCode(), + 'message' => $e->getResponse()->getBody()->getContents(), + ]; + } else if($e->getResponse()->getStatusCode() == 404) { + return [ + 'status' => 'error', + 'code' => $e->getResponse()->getStatusCode(), + 'message' => $e->getResponse()->getBody()->getContents(), + ]; + } + } + return [ + 'status' => 'error', + 'code' => $e->getResponse()->getStatusCode(), + 'message' => $e->getMessage(), + ]; } } From 0f2c75003a64058136282a960b77180ce9571889 Mon Sep 17 00:00:00 2001 From: OoBook Date: Mon, 1 Sep 2025 14:32:28 +0300 Subject: [PATCH 4/7] feat(contracts): add ShouldEmbedForm interface for payment integration - Introduced ShouldEmbedForm interface to define methods for handling built-in form attributes, validating checkout payloads, and hydrating checkout payloads. - This interface aims to standardize the implementation of payment forms across different payment gateways. --- src/Contracts/ShouldEmbedForm.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/Contracts/ShouldEmbedForm.php diff --git a/src/Contracts/ShouldEmbedForm.php b/src/Contracts/ShouldEmbedForm.php new file mode 100644 index 0000000..a535050 --- /dev/null +++ b/src/Contracts/ShouldEmbedForm.php @@ -0,0 +1,30 @@ + Date: Mon, 1 Sep 2025 14:45:46 +0300 Subject: [PATCH 5/7] feat(payment): implement built-in form handling in PaymentService - Added a static property to indicate if the service has a built-in form. - Introduced a method to check for built-in form support and contract implementation. - Enhanced the checkout method to validate and process payloads when a built-in form is available. --- src/Services/PaymentService.php | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Services/PaymentService.php b/src/Services/PaymentService.php index 67d9f0d..55d783d 100644 --- a/src/Services/PaymentService.php +++ b/src/Services/PaymentService.php @@ -3,6 +3,7 @@ namespace Unusualify\Payable\Services; use Illuminate\Support\Str; +use Unusualify\Payable\Contracts\ShouldEmbedForm; abstract class PaymentService extends URequest { @@ -104,6 +105,13 @@ abstract class PaymentService extends URequest */ public static $hasCancel = false; + /** + * Service + * + * @var string + */ + public static $hasBuiltInForm = false; + public const STATUS_PENDING = 'PENDING'; public const STATUS_COMPLETED = 'COMPLETED'; @@ -218,6 +226,19 @@ public function getStatusEnum() return $this->statusEnum; } + /** + * Check if the service has a built-in form and implements the ShouldEmbedForm contract + * + * @return bool + */ + public static function hasBuiltInForm() + { + $implements = class_implements(static::class); + + return (static::$hasBuiltInForm ?? false) + && in_array(ShouldEmbedForm::class, $implements); + } + public function addQueryParameters($url, $payload = []) { $url_parts = parse_url($url); @@ -359,6 +380,26 @@ public static function hasCancel($payload = null) return static::$hasCancel && (method_exists(static::class, 'isCancellable') ? (new static)->isCancellable($payload) : true); } + public function checkout(array $payload) + { + if($this->hasBuiltInForm()) { + $validated = $this->validateCheckoutPayload($payload); + + if($validated !== true) { + return response()->json([ + 'status' => 'error', + 'message' => $validated, + ], 403); + } + + $payload = $this->hydrateCheckoutPayload($payload); + + return $this->getBuiltInFormAttributes($payload); + } + + return []; + } + /** * Validate Refund * From e75fd13c218e837f2dbcecd6bbca1b4c0ad27f0b Mon Sep 17 00:00:00 2001 From: OoBook Date: Mon, 1 Sep 2025 14:46:06 +0300 Subject: [PATCH 6/7] feat(payable): add checkout method for payment processing - Implemented a new checkout method in the Payable class to validate and process payment payloads. - The method merges the payload schema, removes exceptional entries, and creates a payment record before proceeding with the checkout process. --- src/Payable.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Payable.php b/src/Payable.php index 7fb9af3..e7357f7 100644 --- a/src/Payable.php +++ b/src/Payable.php @@ -96,6 +96,21 @@ public function pay($payload, $paymentPayload = []) ->pay($payload, $paymentPayload); } + public function checkout($payload, $paymentPayload = []) + { + $validated = $this->validatePayload($payload); + + $payload = array_merge_recursive_preserve($this->getPayloadSchema(), $payload); + + $payload = $this->removeExceptional($payload); + + $payment = $this->createPaymentRecord($payload, $paymentPayload); + + return $this->service + ->setPayment($payment) + ->checkout($payload); + } + /** * Cancel Payment * @@ -236,7 +251,6 @@ public function createPaymentRecord($payload, $paymentAttributes = []) 'order_id' => $payload['order_id'], 'payment_gateway' => $this->slug, 'status' => $this->statusEnum::PENDING, - 'parameters' => Arr::except($payload, [ 'user_email', 'installment', From 78dd5a91145f9795a4873185ec46c6a28f477467 Mon Sep 17 00:00:00 2001 From: OoBook Date: Mon, 1 Sep 2025 14:46:17 +0300 Subject: [PATCH 7/7] feat(revolut): enhance RevolutService with checkout payload handling and order management - Implemented ShouldEmbedForm interface in RevolutService to support built-in form attributes. - Added methods for validating and hydrating checkout payloads. - Refactored order creation and retrieval methods for better clarity and functionality. - Improved error handling in cancel and refund methods, ensuring proper response structure. - Updated createWidgetOrder method to streamline order processing and response generation. --- src/Services/RevolutService.php | 173 +++++++++++++++++++++++--------- 1 file changed, 127 insertions(+), 46 deletions(-) diff --git a/src/Services/RevolutService.php b/src/Services/RevolutService.php index 091220a..4dad4cf 100644 --- a/src/Services/RevolutService.php +++ b/src/Services/RevolutService.php @@ -3,13 +3,22 @@ namespace Unusualify\Payable\Services; use Illuminate\Http\Request as HttpRequest; -use function config; +use Unusualify\Payable\Contracts\ShouldEmbedForm; + /** * @property \Unusualify\Payable\Models\Payment|null $payment */ -class RevolutService extends PaymentService +class RevolutService extends PaymentService implements ShouldEmbedForm { + /** + * @var bool + */ + public static $hasBuiltInForm = true; + + public static $hasCancel = false; + + public static $hasRefund = true; /** Gateway id */ protected string $service = 'revolut'; @@ -53,16 +62,40 @@ public function setConfig() } /** Minimal validation for widget flow */ - public function validateParams($params) + public function validateCheckoutPayload(array $payload) { $required = ['order_id', 'currency']; - $missing = array_diff($required, array_keys($params)); + $missing = array_diff($required, array_keys($payload)); + if (!empty($missing)) { return 'These keys are missing for Revolut: '.implode(', ', $missing); } + return true; } + public function hydrateCheckoutPayload(array $payload): array + { + $amountMinor = $this->normalizeAmountMinor($payload); + + return [ + 'amount' => $amountMinor, + 'currency' => $payload['currency'] ?? 'EUR', + 'merchant_order_ext_ref' => $payload['order_id'], + 'merchant_customer_ext_ref' => $payload['user_id'] ?? null, + 'description' => $payload['description'] ?? ('Payment for order '.$payload['order_id']), + 'customer' => [ + 'email' => $payload['user_email'] ?? null, + ], + 'metadata' => [ + 'order_id' => $payload['order_id'], + 'user_email' => $payload['user_email'] ?? null, + 'ip' => $payload['user_ip'] ?? null, + ], + // Optional: 'capture_mode' => 'automatic' | 'manual' + ]; + } + /** Convert to minor units */ protected function normalizeAmountMinor(array $params): int { @@ -75,28 +108,11 @@ protected function normalizeAmountMinor(array $params): int /** Build Create Order payload */ public function hydrateParams(array $params): array { - $amountMinor = $this->normalizeAmountMinor($params); - - return [ - 'amount' => $amountMinor, - 'currency' => $params['currency'] ?? 'EUR', - 'merchant_order_ext_ref' => $params['order_id'], - 'merchant_customer_ext_ref' => $params['user_id'] ?? null, - 'description' => $params['description'] ?? ('Payment for order '.$params['order_id']), - 'customer' => [ - 'email' => $params['user_email'] ?? null, - ], - 'metadata' => [ - 'order_id' => $params['order_id'], - 'user_email' => $params['user_email'] ?? null, - 'ip' => $params['user_ip'] ?? null, - ], - // Optional: 'capture_mode' => 'automatic' | 'manual' - ]; + return []; } /** POST /orders */ - protected function createOrder(array $payload): array + protected function createRevolutOrder(array $payload): array { $resp = $this->postReq( $this->url, @@ -111,7 +127,7 @@ protected function createOrder(array $payload): array } /** GET /orders/{id} (optional UX or webhook confirm) */ - protected function retrieveOrder(string $orderId): array + protected function retrieveRevolutOrder(string $orderId): array { $resp = $this->getReq( $this->url, @@ -119,6 +135,7 @@ protected function retrieveOrder(string $orderId): array [], $this->headers ); + return is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; } @@ -126,30 +143,53 @@ protected function retrieveOrder(string $orderId): array * Main entry for Card Field / Card Pop-up. * Creates the order and returns data your JS embed will use. */ - public function createWidgetOrder(array $params): array + public function createWidgetOrder_(array $params): array { $validated = $this->validateParams($params); + if ($validated !== true) return ['error' => $validated]; $payload = $this->hydrateParams($params); - $order = $this->createOrder($payload); - + $revolutOrder = $this->createRevolutOrder($payload); + // Persist PENDING with raw provider response $this->payment?->update([ 'status' => $this->getStatusEnum()::PENDING, - 'response' => $order, + 'response' => $revolutOrder, ]); + dd($this->payment, $revolutOrder); + return [ - 'token' => $order['token'] ?? null, // REQUIRED by RevolutCheckout() - 'order_id' => $order['id'] ?? null, - 'env' => $this->mode === 'production' ? 'production' : 'sandbox', + 'token' => $revolutOrder['token'] ?? null, // REQUIRED by RevolutCheckout() + 'order_id' => $revolutOrder['id'] ?? null, + 'env' => $this->mode === 'live' ? 'prod' : 'sandbox', 'amount' => $payload['amount'] ?? null, 'currency' => $payload['currency'] ?? null, 'email' => $payload['customer']['email'] ?? null, ]; } + public function getBuiltInFormAttributes(array $payload): array + { + $revolutOrder = $this->createRevolutOrder($payload); + + $this->payment?->update([ + 'status' => $this->getStatusEnum()::PENDING, + 'response' => $revolutOrder, + ]); + + return [ + 'token' => $revolutOrder['token'] ?? null, // REQUIRED by RevolutCheckout() + 'paymentId' => $this->payment->id ?? null, + 'orderId' => $this->payment->order_id ?? null, + 'revolutOrderId' => $revolutOrder['id'] ?? null, + 'env' => $this->mode === 'live' ? 'prod' : 'sandbox', + 'amount' => $payload['amount'] ?? null, + 'currency' => $payload['currency'] ?? null, + 'email' => $payload['customer']['email'] ?? null, + ]; + } /** * Optional: keep pay() compatible — just return directive for widget flow. * Your controller/UI should call createWidgetOrder() and then run the JS. @@ -157,15 +197,24 @@ public function createWidgetOrder(array $params): array public function pay(array $params) { $data = $this->createWidgetOrder($params); + if (!empty($data['token'])) { - return [ + return response()->json([ 'type' => 'widget', 'order_id' => $data['order_id'], 'token' => $data['token'], 'env' => $data['env'], 'message' => 'Use Card Field or Pop-up with RevolutCheckout(token, env).', - ]; + ]); + // return [ + // 'type' => 'widget', + // 'order_id' => $data['order_id'], + // 'token' => $data['token'], + // 'env' => $data['env'], + // 'message' => 'Use Card Field or Pop-up with RevolutCheckout(token, env).', + // ]; } + return ['type' => 'error', 'message' => $data['error'] ?? 'Unable to initialize Revolut order']; } @@ -229,16 +278,30 @@ public function handleWebhook(HttpRequest $request) } /** POST /orders/{id}/cancel (void before capture) */ - public function cancel(array $params) + public function cancel(array|object $params) { - $orderId = $params['order_id'] ?? $this->payment->order_id ?? null; + + $params = (array) $params; + + $orderId = $params['id'] ?? $this->payment->response->id ?? null; + if (!$orderId) return ['status' => 'error', 'message' => 'Order ID is required']; $resp = $this->postReq($this->url, '/orders/'.urlencode($orderId).'/cancel', (object)[], $this->headers, 'json', $this->mode); - $data = is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; + + $statusCode = 200; + $message = 'Cancel successful'; + if(is_array($resp) && isset($resp['status']) && $resp['status'] == 'error') { + $message = json_decode($resp['message'], true)['message']; + $statusCode = $resp['code'] ?? 500; + $data = $resp; + } else { + $data = is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; + } $status = self::RESPONSE_STATUS_ERROR; - if (!isset($data['error'])) { + + if (!isset($data['status']) || $data['status'] !== 'error') { $this->payment?->update([ 'status' => $this->getStatusEnum()::CANCELLED, 'response' => $data, @@ -249,6 +312,8 @@ public function cancel(array $params) return [ 'type' => 'cancel', 'status' => $status, + 'code' => $statusCode, + 'message' => $message, 'id' => $this->payment->id ?? ($params['payment_id'] ?? null), 'payment_service' => $this->service, 'order_data' => json_encode($data), @@ -259,37 +324,53 @@ public function cancel(array $params) public function refund(array|object $params) { $refundRequest = $this->validateRefundRequest($params); + if (!$refundRequest['validated']) return $refundRequest; $params = (array) $params; $payment = $refundRequest['payment'] ?? null; - $orderId = $params['order_id'] ?? ($payment->order_id ?? null); + $orderId = $params['id'] ?? ($payment->response->id ?? null); + $currency = $params['currency'] ?? ($payment->response->currency ?? null); if (!$orderId) { return array_merge($refundRequest, ['message' => 'order_id is required for refund']); } + if (!$currency) { + return array_merge($refundRequest, ['message' => 'currency is required for refund']); + } + $payload = []; if (!empty($params['amount'])) $payload['amount'] = (int) $params['amount']; // minor + if (!empty($currency)) $payload['currency'] = $currency; $resp = $this->postReq($this->url, '/orders/'.urlencode($orderId).'/refund', $payload, $this->headers, 'json', $this->mode); + $data = is_string($resp) ? (array) json_decode($resp, true) : (array) $resp; - $status = self::RESPONSE_STATUS_ERROR; - $message = 'Refund failed'; + $status = self::RESPONSE_STATUS_SUCCESS; + $message = 'Refunded successfully'; + $revolutMessageObject = isset($data['message']) ? json_decode($data['message'], true) : []; + + $paymentPayload = []; + - if (!isset($data['error'])) { - $payment?->update([ + if(isset($data['code'])) { + $status = self::RESPONSE_STATUS_ERROR; + } else { + $payment->update([ 'status' => $this->getStatusEnum()::REFUNDED, 'response' => $data, ]); - $status = self::RESPONSE_STATUS_SUCCESS; - $message = 'Refunded successfully'; + } + + if(isset($revolutMessageObject['message'])) { + $message = $revolutMessageObject['message']; } return array_merge($refundRequest, [ 'status' => $status, - 'order_data' => json_encode($data), + 'order_data' => $data, 'message' => $message, ]); } @@ -325,4 +406,4 @@ public function generatePostForm($params, $actionUrl = null) { return parent::generatePostForm($params, $actionUrl); } -} \ No newline at end of file +}