From 93f1a0401a560f8ea7e878bfc81eab280feed95a Mon Sep 17 00:00:00 2001 From: Oness Date: Thu, 15 Jan 2026 08:26:42 +0000 Subject: [PATCH 1/7] implemented initial razorpay payments --- .../DomainObjects/Enums/PaymentProviders.php | 1 + .../RazorpayOrderDomainObjectAbstract.php | 174 +++++++++++++++ .../app/DomainObjects/OrderDomainObject.php | 25 ++- .../RazorpayOrderDomainObject.php | 25 +++ .../Razorpay/CreateOrderFailedException.php | 8 + .../Razorpay/InvalidSignatureException.php | 13 ++ .../PaymentVerificationFailedException.php | 8 + .../Exceptions/Razorpay/RazorpayException.php | 10 + .../CreateRazorpayOrderActionPublic.php | 34 +++ .../VerifyRazorpayPaymentActionPublic.php | 35 +++ backend/app/Models/Order.php | 5 + backend/app/Models/RazorpayOrder.php | 33 +++ .../Providers/RepositoryServiceProvider.php | 3 + .../Eloquent/RazorpayOrdersRepository.php | 64 ++++++ .../Interfaces/RazorpayOrderInterface.php | 28 +++ .../RazorpayOrdersRepositoryInterface.php | 29 +++ .../Razorpay/CreateRazorpayOrderHandler.php | 105 +++++++++ .../Razorpay/VerifyRazorpayPaymentHandler.php | 73 ++++++ .../DTOs/CreateRazorpayOrderRequestDTO.php | 48 ++++ .../DTOs/CreateRazorpayOrderResponseDTO.php | 14 ++ .../Razorpay/RazorpayOrderCreationService.php | 118 ++++++++++ .../RazorpayPaymentVerificationService.php | 75 +++++++ .../Razorpay/RazorpayClientFactory.php | 27 +++ backend/composer.json | 1 + backend/composer.lock | 157 ++++++++++++- backend/config/services.php | 7 + ...14_074419_create_razorpay_orders_table.php | 37 ++++ backend/routes/api.php | 6 + frontend/src/api/order.client.ts | 26 +++ .../Sections/PaymentSettings/index.tsx | 100 +++++++-- .../Payment/PaymentMethods/Razorpay/index.tsx | 208 ++++++++++++++++++ .../routes/product-widget/Payment/index.tsx | 38 ++-- .../src/queries/useCreateRazorpayOrder.ts | 23 ++ frontend/src/types.ts | 2 +- 34 files changed, 1518 insertions(+), 42 deletions(-) create mode 100644 backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/RazorpayOrderDomainObject.php create mode 100644 backend/app/Exceptions/Razorpay/CreateOrderFailedException.php create mode 100644 backend/app/Exceptions/Razorpay/InvalidSignatureException.php create mode 100644 backend/app/Exceptions/Razorpay/PaymentVerificationFailedException.php create mode 100644 backend/app/Exceptions/Razorpay/RazorpayException.php create mode 100644 backend/app/Http/Actions/Orders/Payment/Razorpay/CreateRazorpayOrderActionPublic.php create mode 100644 backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php create mode 100644 backend/app/Models/RazorpayOrder.php create mode 100644 backend/app/Repository/Eloquent/RazorpayOrdersRepository.php create mode 100644 backend/app/Repository/Interfaces/RazorpayOrderInterface.php create mode 100644 backend/app/Repository/Interfaces/RazorpayOrdersRepositoryInterface.php create mode 100644 backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php create mode 100644 backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderRequestDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderResponseDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/RazorpayOrderCreationService.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationService.php create mode 100644 backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php create mode 100644 backend/database/migrations/2026_01_14_074419_create_razorpay_orders_table.php create mode 100644 frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx create mode 100644 frontend/src/queries/useCreateRazorpayOrder.ts diff --git a/backend/app/DomainObjects/Enums/PaymentProviders.php b/backend/app/DomainObjects/Enums/PaymentProviders.php index 8eb53645c9..46f42bce1f 100644 --- a/backend/app/DomainObjects/Enums/PaymentProviders.php +++ b/backend/app/DomainObjects/Enums/PaymentProviders.php @@ -7,5 +7,6 @@ enum PaymentProviders: string use BaseEnum; case STRIPE = 'STRIPE'; + case RAZORPAY = 'RAZORPAY'; case OFFLINE = 'OFFLINE'; } diff --git a/backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php new file mode 100644 index 0000000000..c0a5b34069 --- /dev/null +++ b/backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php @@ -0,0 +1,174 @@ + $this->id ?? null, + 'order_id' => $this->order_id ?? null, + 'razorpay_order_id' => $this->razorpay_order_id ?? null, + 'razorpay_payment_id' => $this->razorpay_payment_id ?? null, + 'razorpay_signature' => $this->razorpay_signature ?? null, + 'amount' => $this->amount ?? null, + 'currency' => $this->currency ?? null, + 'receipt' => $this->receipt ?? null, + 'payment_status' => $this->payment_status ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_id; + } + + public function setRazorpayOrderId(string $razorpay_order_id): self + { + $this->razorpay_order_id = $razorpay_order_id; + return $this; + } + + public function getRazorpayOrderId(): string + { + return $this->razorpay_order_id; + } + + public function setRazorpayPaymentId(?string $razorpay_payment_id): self + { + $this->razorpay_payment_id = $razorpay_payment_id; + return $this; + } + + public function getRazorpayPaymentId(): ?string + { + return $this->razorpay_payment_id; + } + + public function setRazorpaySignature(?string $razorpay_signature): self + { + $this->razorpay_signature = $razorpay_signature; + return $this; + } + + public function getRazorpaySignature(): ?string + { + return $this->razorpay_signature; + } + + public function setAmount(int $amount): self + { + $this->amount = $amount; + return $this; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function setCurrency(string $currency): self + { + $this->currency = $currency; + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setReceipt(?string $receipt): self + { + $this->receipt = $receipt; + return $this; + } + + public function getReceipt(): ?string + { + return $this->receipt; + } + + public function setPaymentStatus(string $payment_status): self + { + $this->payment_status = $payment_status; + return $this; + } + + public function getPaymentStatus(): string + { + return $this->payment_status; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/OrderDomainObject.php b/backend/app/DomainObjects/OrderDomainObject.php index 723e98b904..e2492d7a08 100644 --- a/backend/app/DomainObjects/OrderDomainObject.php +++ b/backend/app/DomainObjects/OrderDomainObject.php @@ -24,6 +24,8 @@ class OrderDomainObject extends Generated\OrderDomainObjectAbstract implements I public ?Collection $attendees = null; public ?StripePaymentDomainObject $stripePayment = null; + + public ?RazorpayOrderDomainObject $razorpayOrder = null; /** @var Collection|null */ public ?Collection $questionAndAnswerViews = null; @@ -180,6 +182,12 @@ public function setStripePayment(?StripePaymentDomainObject $stripePayment): Ord $this->stripePayment = $stripePayment; return $this; } + + public function setRazorpayOrder(?RazorpayOrderDomainObject $razorpayOrder): OrderDomainObject + { + $this->razorpayOrder = $razorpayOrder; + return $this; + } public function isPartiallyRefunded(): bool { @@ -220,6 +228,11 @@ public function getStripePayment(): ?StripePaymentDomainObject { return $this->stripePayment; } + + public function getRazorpayOrder(): ?RazorpayOrderDomainObject + { + return $this->razorpayOrder; + } public function isFreeOrder(): bool { @@ -286,4 +299,14 @@ public function isRefundable(): bool && $this->getPaymentProvider() === PaymentProviders::STRIPE->name && $this->getRefundStatus() !== OrderRefundStatus::REFUNDED->name; } -} + + public function isRazorpayOrder(): bool + { + return $this->getPaymentProvider() === PaymentProviders::RAZORPAY->name; + } + + public function hasRazorpayOrder(): bool + { + return $this->razorpayOrder !== null; + } +} \ No newline at end of file diff --git a/backend/app/DomainObjects/RazorpayOrderDomainObject.php b/backend/app/DomainObjects/RazorpayOrderDomainObject.php new file mode 100644 index 0000000000..3dda293706 --- /dev/null +++ b/backend/app/DomainObjects/RazorpayOrderDomainObject.php @@ -0,0 +1,25 @@ +payment_status, ['captured', 'paid']); + } + + public function isFailed(): bool + { + return $this->payment_status === 'failed'; + } + + public function isPending(): bool + { + return $this->payment_status === 'created'; + } +} \ No newline at end of file diff --git a/backend/app/Exceptions/Razorpay/CreateOrderFailedException.php b/backend/app/Exceptions/Razorpay/CreateOrderFailedException.php new file mode 100644 index 0000000000..77e08ed0ac --- /dev/null +++ b/backend/app/Exceptions/Razorpay/CreateOrderFailedException.php @@ -0,0 +1,8 @@ +createRazorpayOrderHandler->handle($orderShortId); + } catch (CreateOrderFailedException $e) { + return $this->errorResponse($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->jsonResponse([ + 'razorpay_order_id' => $razorpayOrder->id, + 'key_id' => $razorpayOrder->keyId, + 'amount' => $razorpayOrder->amount, + 'currency' => $razorpayOrder->currency, + ]); + } +} \ No newline at end of file diff --git a/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php new file mode 100644 index 0000000000..85935aa0c0 --- /dev/null +++ b/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php @@ -0,0 +1,35 @@ +verifyRazorpayPaymentHandler->handle( + $orderShortId, + request()->all() + ); + } catch (PaymentVerificationFailedException $e) { + return $this->errorResponse($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->jsonResponse([ + 'message' => __('Payment verified successfully'), + 'order' => $order->toArray(), + ]); + } +} \ No newline at end of file diff --git a/backend/app/Models/Order.php b/backend/app/Models/Order.php index e69cdad97e..0508cf9d31 100644 --- a/backend/app/Models/Order.php +++ b/backend/app/Models/Order.php @@ -21,6 +21,11 @@ public function stripe_payment(): HasOne return $this->hasOne(StripePayment::class); } + public function razorpay_order(): HasOne + { + return $this->hasOne(RazorpayOrder::class); + } + public function order_items(): HasMany { return $this->hasMany(OrderItem::class); diff --git a/backend/app/Models/RazorpayOrder.php b/backend/app/Models/RazorpayOrder.php new file mode 100644 index 0000000000..923a199762 --- /dev/null +++ b/backend/app/Models/RazorpayOrder.php @@ -0,0 +1,33 @@ + 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} \ No newline at end of file diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 712839ee35..11fd05d502 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -42,6 +42,7 @@ use HiEvents\Repository\Eloquent\QuestionAndAnswerViewRepository; use HiEvents\Repository\Eloquent\QuestionAnswerRepository; use HiEvents\Repository\Eloquent\QuestionRepository; +use HiEvents\Repository\Eloquent\RazorpayOrdersRepository; use HiEvents\Repository\Eloquent\StripeCustomerRepository; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\StripePayoutsRepository; @@ -88,6 +89,7 @@ use HiEvents\Repository\Interfaces\QuestionAndAnswerViewRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; use HiEvents\Repository\Interfaces\StripeCustomerRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Repository\Interfaces\StripePayoutsRepositoryInterface; @@ -116,6 +118,7 @@ class RepositoryServiceProvider extends ServiceProvider QuestionRepositoryInterface::class => QuestionRepository::class, QuestionAnswerRepositoryInterface::class => QuestionAnswerRepository::class, StripePaymentsRepositoryInterface::class => StripePaymentsRepository::class, + RazorpayOrdersRepositoryInterface::class => RazorpayOrdersRepository::class, PromoCodeRepositoryInterface::class => PromoCodeRepository::class, MessageRepositoryInterface::class => MessageRepository::class, PasswordResetTokenRepositoryInterface::class => PasswordResetTokenRepository::class, diff --git a/backend/app/Repository/Eloquent/RazorpayOrdersRepository.php b/backend/app/Repository/Eloquent/RazorpayOrdersRepository.php new file mode 100644 index 0000000000..2ea6036ea0 --- /dev/null +++ b/backend/app/Repository/Eloquent/RazorpayOrdersRepository.php @@ -0,0 +1,64 @@ +findFirstWhere([ + 'razorpay_order_id' => $razorpayOrderId, + ]); + } + + public function findByOrderId(int $orderId): ?RazorpayOrderDomainObject + { + return $this->findFirstWhere([ + 'order_id' => $orderId, + ]); + } + + public function updateByOrderId(int $orderId, array $data): bool + { + $model = $this->model + ->where('order_id', $orderId) + ->first(); + + if (!$model) { + return false; + } + + return $model->update($data); + } + + public function findByPaymentId(string $paymentId): ?RazorpayOrderDomainObject + { + return $this->findFirstWhere([ + 'razorpay_payment_id' => $paymentId, + ]); + } + + protected function applySoftDeleteFilter(Builder $query): Builder + { + // Razorpay orders are not soft deleted + return $query; + } +} \ No newline at end of file diff --git a/backend/app/Repository/Interfaces/RazorpayOrderInterface.php b/backend/app/Repository/Interfaces/RazorpayOrderInterface.php new file mode 100644 index 0000000000..e0b13cab07 --- /dev/null +++ b/backend/app/Repository/Interfaces/RazorpayOrderInterface.php @@ -0,0 +1,28 @@ +orderRepository + ->loadRelation(new Relationship(OrderItemDomainObject::class)) + ->loadRelation(new Relationship(RazorpayOrderDomainObject::class, name: 'razorpay_order')) + ->findByShortId($orderShortId); + + if (!$order || !$this->sessionIdentifierService->verifySession($order->getSessionId())) { + throw new UnauthorizedException(__('Sorry, we could not verify your session. Please create a new order.')); + } + + if ($order->getStatus() !== OrderStatus::RESERVED->name || $order->isReservedOrderExpired()) { + throw new ResourceConflictException(__('Sorry, is expired or not in a valid state.')); + } + + $account = $this->accountRepository + ->loadRelation(new Relationship( + domainObject: AccountConfigurationDomainObject::class, + name: 'configuration', + )) + ->findByEventId($order->getEventId()); + + // Check if we already have a Razorpay order + if ($order->getRazorpayOrder() !== null) { + return new CreateRazorpayOrderResponseDTO( + id: $order->getRazorpayOrder()->getRazorpayOrderId(), + keyId: config('services.razorpay.key_id'), + amount: $order->getRazorpayOrder()->getAmount(), + currency: $order->getRazorpayOrder()->getCurrency(), + ); + } + + $razorpayOrder = $this->razorpayOrderService->createOrder( + CreateRazorpayOrderRequestDTO::fromArray([ + 'amount' => MoneyValue::fromFloat($order->getTotalGross(), $order->getCurrency()), + 'currencyCode' => $order->getCurrency(), + 'account' => $account, + 'order' => $order, + ]) + ); + + // Store Razorpay order in database + $this->razorpayOrdersRepository->create([ + 'order_id' => $order->getId(), + 'razorpay_order_id' => $razorpayOrder->id, + 'amount' => $razorpayOrder->amount, + 'currency' => strtoupper($order->getCurrency()), + 'receipt' => $order->getShortId(), + ]); + + return new CreateRazorpayOrderResponseDTO( + id: $razorpayOrder->id, + keyId: config('services.razorpay.key_id'), + amount: $razorpayOrder->amount, + currency: $razorpayOrder->currency, + ); + } +} \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php new file mode 100644 index 0000000000..df78d9c564 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php @@ -0,0 +1,73 @@ +orderRepository + ->loadRelation(new Relationship(OrderItemDomainObject::class)) + ->loadRelation(new Relationship(RazorpayOrderDomainObject::class, name: 'razorpay_order')) + ->findByShortId($orderShortId); + + if (!$order || !$this->sessionIdentifierService->verifySession($order->getSessionId())) { + throw new UnauthorizedException(__('Sorry, we could not verify your session. Please create a new order.')); + } + + if ($order->getStatus() !== OrderStatus::RESERVED->name || $order->isReservedOrderExpired()) { + throw new ResourceConflictException(__('Sorry, is expired or not in a valid state.')); + } + + // Verify the payment signature + $isValid = $this->razorpayPaymentService->verifyPaymentSignature($paymentData); + + if (!$isValid) { + throw new PaymentVerificationFailedException(__('Payment verification failed. Please try again.')); + } + + // Update Razorpay order with payment details + $this->razorpayOrdersRepository->updateByOrderId($order->getId(), [ + 'razorpay_payment_id' => $paymentData['razorpay_payment_id'], + 'razorpay_signature' => $paymentData['razorpay_signature'], + 'payment_status' => 'captured', + ]); + + // Update order status to completed + $order->setStatus(OrderStatus::COMPLETED->name); + $order->setPaymentStatus('PAYMENT_RECEIVED'); + $this->orderRepository->updateFromArray($order->getId(), [ + 'status' => $order->getStatus(), + 'payment_status' => $order->getPaymentStatus(), + ]); + + return $order; + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderRequestDTO.php b/backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderRequestDTO.php new file mode 100644 index 0000000000..ee12b8af38 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderRequestDTO.php @@ -0,0 +1,48 @@ + $this->amount, + 'currencyCode' => $this->currencyCode, + 'account' => in_array('account', $except) ? '[object]' : $this->account->toArray(), + 'order' => in_array('order', $except) ? '[object]' : $this->order->toArray(), + 'vatSettings' => $this->vatSettings?->toArray(), + ]; + + foreach ($except as $key) { + unset($data[$key]); + } + + return $data; + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderResponseDTO.php b/backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderResponseDTO.php new file mode 100644 index 0000000000..9a2475f560 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/DTOs/CreateRazorpayOrderResponseDTO.php @@ -0,0 +1,14 @@ +databaseManager->beginTransaction(); + + $razorpayClient = $this->razorpayClientFactory->create(); + + // Calculate application fee for Razorpay + $applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee( + accountConfiguration: $orderDTO->account->getConfiguration(), + order: $orderDTO->order, + vatSettings: $orderDTO->account->getAccountVatSetting(), + ); + + // Razorpay amount is in paise (Indian) or smallest currency unit + $amountInSmallestUnit = $orderDTO->amount->toMinorUnit(); + + // For INR, amount is in paise + // For other currencies, check Razorpay documentation for conversion + if ($orderDTO->currencyCode !== 'INR') { + // Razorpay supports multiple currencies but amounts might need different handling + // This depends on Razorpay's currency requirements + $amountInSmallestUnit = $orderDTO->amount->toFloat() * 100; // Default conversion + } + + $orderData = [ + 'amount' => $amountInSmallestUnit, + 'currency' => $orderDTO->currencyCode, + 'receipt' => $orderDTO->order->getShortId(), + 'payment_capture' => 1, // Auto-capture payment + 'notes' => [ + 'order_id' => $orderDTO->order->getId(), + 'event_id' => $orderDTO->order->getEventId(), + 'order_short_id' => $orderDTO->order->getShortId(), + 'account_id' => $orderDTO->account->getId(), + ], + ]; + + // Add application fee if applicable (Razorpay handles fees differently) + if ($applicationFee && $this->config->get('services.razorpay.application_fee_enabled')) { + $orderData['transfers'] = [ + [ + 'account' => $this->config->get('services.razorpay.platform_account_id'), + 'amount' => $applicationFee->grossApplicationFee->toMinorUnit(), + 'currency' => $orderDTO->currencyCode, + ] + ]; + } + + $razorpayOrder = $razorpayClient->order->create($orderData); + + $this->logger->debug('Razorpay order created', [ + 'razorpayOrderId' => $razorpayOrder->id, + 'orderDTO' => $orderDTO->toArray(['account']), + ]); + + $this->databaseManager->commit(); + + return new CreateRazorpayOrderResponseDTO( + id: $razorpayOrder->id, + keyId: $this->config->get('services.razorpay.key_id'), + amount: $razorpayOrder->amount, + currency: $razorpayOrder->currency, + receipt: $razorpayOrder->receipt, + ); + } catch (Error $exception) { + dd($exception); + $this->logger->error("Razorpay order creation failed: {$exception->getMessage()}", [ + 'exception' => $exception, + 'orderDTO' => $orderDTO->toArray(['account']), + ]); + + $this->databaseManager->rollBack(); + + throw new CreateOrderFailedException( + __('There was an error communicating with the payment provider. Please try again later.') + ); + } catch (Throwable $exception) { + $this->databaseManager->rollBack(); + + throw $exception; + } + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationService.php b/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationService.php new file mode 100644 index 0000000000..501d5cb69a --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationService.php @@ -0,0 +1,75 @@ +config->get('services.razorpay.key_secret') + ); + + if ($expectedSignature !== $paymentData['razorpay_signature']) { + $this->logger->error('Razorpay signature verification failed', [ + 'expected' => $expectedSignature, + 'received' => $paymentData['razorpay_signature'], + 'order_id' => $paymentData['razorpay_order_id'], + 'payment_id' => $paymentData['razorpay_payment_id'], + ]); + + throw new InvalidSignatureException(); + } + + return true; + } + + public function verifyWebhookSignature(string $payload, string $signature): bool + { + $expectedSignature = hash_hmac( + 'sha256', + $payload, + $this->config->get('services.razorpay.webhook_secret') + ); + + return hash_equals($expectedSignature, $signature); + } + + public function fetchPaymentDetails(string $paymentId): array + { + try { + $razorpayClient = $this->razorpayClientFactory->create(); + $payment = $razorpayClient->payment->fetch($paymentId); + + return [ + 'id' => $payment->id, + 'amount' => $payment->amount, + 'currency' => $payment->currency, + 'status' => $payment->status, + 'order_id' => $payment->order_id, + 'method' => $payment->method, + 'created_at' => $payment->created_at, + ]; + } catch (\Exception $e) { + $this->logger->error('Failed to fetch Razorpay payment details', [ + 'payment_id' => $paymentId, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } +} \ No newline at end of file diff --git a/backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php b/backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php new file mode 100644 index 0000000000..ab2284676d --- /dev/null +++ b/backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php @@ -0,0 +1,27 @@ +config->get('services.razorpay.key_id'); + $keySecret = $this->config->get('services.razorpay.key_secret'); + + if (!$keyId || !$keySecret) { + throw new \RuntimeException('Razorpay credentials not configured'); + } + + $api = new Api($keyId, $keySecret); + + return $api; + } +} \ No newline at end of file diff --git a/backend/composer.json b/backend/composer.json index 8dbd2ad25e..f5f1b4ba2a 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -24,6 +24,7 @@ "maatwebsite/excel": "^3.1", "nette/php-generator": "^4.0", "php-open-source-saver/jwt-auth": "^2.1", + "razorpay/razorpay": "2.*", "sentry/sentry-laravel": "^4.13", "spatie/icalendar-generator": "^3.0", "spatie/laravel-data": "^4.15", diff --git a/backend/composer.lock b/backend/composer.lock index c6c34f0982..0c09139507 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7649da1e3e0f8fad888953eb259e42b7", + "content-hash": "002f0dfe334a637d98a2799bd351e784", "packages": [ { "name": "amphp/amp", @@ -6863,6 +6863,71 @@ }, "time": "2025-09-04T20:59:21+00:00" }, + { + "name": "razorpay/razorpay", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/razorpay/razorpay-php.git", + "reference": "c5cf59941eb2d888e80371328d932e6e8266d352" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/razorpay/razorpay-php/zipball/c5cf59941eb2d888e80371328d932e6e8266d352", + "reference": "c5cf59941eb2d888e80371328d932e6e8266d352", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.3", + "rmccue/requests": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "raveren/kint": "1.*" + }, + "type": "library", + "autoload": { + "files": [ + "Deprecated.php" + ], + "psr-4": { + "Razorpay\\Api\\": "src/", + "Razorpay\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Abhay Rana", + "email": "nemo@razorpay.com", + "homepage": "https://captnemo.in", + "role": "Developer" + }, + { + "name": "Shashank Kumar", + "email": "shashank@razorpay.com", + "role": "Developer" + } + ], + "description": "Razorpay PHP Client Library", + "homepage": "https://docs.razorpay.com", + "keywords": [ + "api", + "client", + "php", + "razorpay" + ], + "support": { + "email": "contact@razorpay.com", + "issues": "https://github.com/Razorpay/razorpay-php/issues", + "source": "https://github.com/Razorpay/razorpay-php" + }, + "time": "2025-08-05T07:13:20+00:00" + }, { "name": "revolt/event-loop", "version": "v1.0.7", @@ -6991,6 +7056,92 @@ }, "time": "2025-04-29T08:38:14+00:00" }, + { + "name": "rmccue/requests", + "version": "v2.0.17", + "source": { + "type": "git", + "url": "https://github.com/WordPress/Requests.git", + "reference": "74d1648cc34e16a42ea25d548fc73ec107a90421" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/Requests/zipball/74d1648cc34e16a42ea25d548fc73ec107a90421", + "reference": "74d1648cc34e16a42ea25d548fc73ec107a90421", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.6" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.0", + "php-parallel-lint/php-console-highlighter": "^0.5.0", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "requests/test-server": "dev-main", + "squizlabs/php_codesniffer": "^3.6", + "wp-coding-standards/wpcs": "^2.0", + "yoast/phpunit-polyfills": "^1.1.5" + }, + "suggest": { + "art4/requests-psr18-adapter": "For using Requests as a PSR-18 HTTP Client", + "ext-curl": "For improved performance", + "ext-openssl": "For secure transport support", + "ext-zlib": "For improved performance when decompressing encoded streams" + }, + "type": "library", + "autoload": { + "files": [ + "library/Deprecated.php" + ], + "psr-4": { + "WpOrg\\Requests\\": "src/" + }, + "classmap": [ + "library/Requests.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Ryan McCue", + "homepage": "https://rmccue.io/" + }, + { + "name": "Alain Schlesser", + "homepage": "https://github.com/schlessera" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl" + }, + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/Requests/graphs/contributors" + } + ], + "description": "A HTTP library written in PHP, for human beings.", + "homepage": "https://requests.ryanmccue.info/", + "keywords": [ + "curl", + "fsockopen", + "http", + "idna", + "ipv6", + "iri", + "sockets" + ], + "support": { + "docs": "https://requests.ryanmccue.info/", + "issues": "https://github.com/WordPress/Requests/issues", + "source": "https://github.com/WordPress/Requests" + }, + "time": "2025-12-12T17:47:19+00:00" + }, { "name": "sabberworm/php-css-parser", "version": "v8.8.0", @@ -13701,6 +13852,6 @@ "ext-intl": "*", "ext-xmlwriter": "*" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.2.0" } diff --git a/backend/config/services.php b/backend/config/services.php index 44f123a1e7..d675543d7b 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -52,4 +52,11 @@ 'open_exchange_rates' => [ 'app_id' => env('OPEN_EXCHANGE_RATES_APP_ID'), ], + + 'razorpay' => [ + 'key_id' => env('RAZORPAY_KEY_ID'), + 'key_secret' => env('RAZORPAY_KEY_SECRET'), + 'application_fee_enabled' => env('RAZORPAY_APPLICATION_FEE_ENABLED', true), + 'platform_account_id' => env('RAZORPAY_PLATFORM_ACCOUNT_ID'), + ], ]; diff --git a/backend/database/migrations/2026_01_14_074419_create_razorpay_orders_table.php b/backend/database/migrations/2026_01_14_074419_create_razorpay_orders_table.php new file mode 100644 index 0000000000..ad1671b820 --- /dev/null +++ b/backend/database/migrations/2026_01_14_074419_create_razorpay_orders_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->onDelete('cascade'); + $table->string('razorpay_order_id')->unique(); + $table->string('razorpay_payment_id')->nullable(); + $table->string('razorpay_signature')->nullable(); + $table->integer('amount'); + $table->string('currency', 3); + $table->string('receipt')->nullable(); + $table->string('payment_status')->default('created'); + $table->timestamps(); + + $table->index(['order_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('razorpay_orders'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index c84c87fe99..e60d95a60f 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -86,6 +86,8 @@ use HiEvents\Http\Actions\Orders\GetOrdersAction; use HiEvents\Http\Actions\Orders\MarkOrderAsPaidAction; use HiEvents\Http\Actions\Orders\MessageOrderAction; +use HiEvents\Http\Actions\Orders\Payment\Razorpay\CreateRazorpayOrderActionPublic; +use HiEvents\Http\Actions\Orders\Payment\Razorpay\VerifyRazorpayPaymentActionPublic; use HiEvents\Http\Actions\Orders\Payment\RefundOrderAction; use HiEvents\Http\Actions\Orders\Payment\Stripe\CreatePaymentIntentActionPublic; use HiEvents\Http\Actions\Orders\Payment\Stripe\GetPaymentIntentActionPublic; @@ -478,6 +480,10 @@ function (Router $router): void { // Stripe payment gateway $router->post('/events/{event_id}/order/{order_short_id}/stripe/payment_intent', CreatePaymentIntentActionPublic::class); $router->get('/events/{event_id}/order/{order_short_id}/stripe/payment_intent', GetPaymentIntentActionPublic::class); + + // Razorpay payment gateway + $router->post('/events/{event_id}/order/{order_short_id}/razorpay/order', CreateRazorpayOrderActionPublic::class); + $router->post('/events/{event_id}/order/{order_short_id}/razorpay/verify', VerifyRazorpayPaymentActionPublic::class); // Questions $router->get('/events/{event_id}/questions', GetQuestionsPublicAction::class); diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts index c54d8e5a06..75ad4cdb0d 100644 --- a/frontend/src/api/order.client.ts +++ b/frontend/src/api/order.client.ts @@ -154,6 +154,32 @@ export const orderClientPublic = { return response.data; }, + createRazorpayOrder: async (eventId: number, orderShortId: string) => { + const response = await publicApi.post<{ + razorpay_order_id: string, + key_id: string, + amount: number, + currency: string, + }>(`events/${eventId}/order/${orderShortId}/razorpay/order`); + return response.data; + }, + + verifyRazorpayPayment: async ( + eventId: number, + orderShortId: string, + payload: { + razorpay_payment_id: string, + razorpay_order_id: string, + razorpay_signature: string, + } + ) => { + const response = await publicApi.post>( + `events/${eventId}/order/${orderShortId}/razorpay/verify`, + payload + ); + return response.data; + }, + finaliseOrder: async ( eventId: number, orderShortId: string, diff --git a/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx index 98bd27babc..6fc5cdf3cf 100644 --- a/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx @@ -81,17 +81,24 @@ export const PaymentAndInvoicingSettings = () => { const paymentOptions = [ { - value: "STRIPE", + value: 'STRIPE', label: t`Stripe`, - description: t`Accept credit card payments with Stripe` + description: t`Accept credit card payments with Stripe`, + group: 'ONLINE', }, { - value: "OFFLINE", + value: 'RAZORPAY', + label: t`Razorpay`, + description: t`Accept credit card payments with Razorpay`, + group: 'ONLINE', + }, + { + value: 'OFFLINE', label: t`Offline Payments`, - description: t`Accept bank transfers, checks, or other offline payment methods` + description: t`Accept bank transfers, checks, or other offline payment methods`, + group: 'OFFLINE', }, ]; - return ( { {t`Payment Methods`} - {paymentOptions.map((option) => ( - { - const checked = event.currentTarget.checked; - const currentValues = form.values.payment_providers || []; - form.setFieldValue( - 'payment_providers', - checked - ? [...currentValues, option.value as PaymentProvider] - : currentValues.filter(v => v !== option.value) - ); - }} - mb="sm" - /> - ))} + + {/* Online Payments Section */} + + {t`Online Payments`} + + {t`Accept online payments via third-party payment providers`} + + + + {paymentOptions + .filter(option => option.group === "ONLINE") + .map((option) => ( + { + const checked = event.currentTarget.checked; + const currentValues = form.values.payment_providers || []; + + if (checked) { + const filtered = currentValues.filter( + v => v !== "STRIPE" && v !== "RAZORPAY" + ); + form.setFieldValue('payment_providers', [...filtered, option.value as PaymentProvider]); + } else { + form.setFieldValue( + 'payment_providers', + currentValues.filter(v => v !== option.value) + ); + } + }} + /> + )) + } + + + + {/* Offline Payments Section */} + + + {t`Offline Payments`} + + {t`Accept bank transfers, checks, or other offline payment methods`} + + + } + checked={form.values.payment_providers?.includes("OFFLINE")} + onChange={(event) => { + const checked = event.currentTarget.checked; + const currentValues = form.values.payment_providers || []; + form.setFieldValue( + 'payment_providers', + checked + ? [...currentValues, "OFFLINE"] + : currentValues.filter(v => v !== "OFFLINE") + ); + }} + /> + + {form.errors["payment_providers"] && ( {form.errors["payment_providers"]} )} diff --git a/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx new file mode 100644 index 0000000000..790f6121b3 --- /dev/null +++ b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx @@ -0,0 +1,208 @@ +import {useEffect, useState} from "react"; +import {useNavigate, useParams} from "react-router"; +import {useCreateRazorpayOrder} from "../../../../../../queries/useCreateRazorpayOrder.ts"; +import {useGetEventPublic} from "../../../../../../queries/useGetEventPublic.ts"; +import {CheckoutContent} from "../../../../../layouts/Checkout/CheckoutContent"; +import {HomepageInfoMessage} from "../../../../../common/HomepageInfoMessage"; +import {t} from "@lingui/macro"; +import {eventHomepagePath} from "../../../../../../utilites/urlHelper.ts"; +import {LoadingMask} from "../../../../../common/LoadingMask"; +import {Event} from "../../../../../../types.ts"; +import {useGetOrderPublic} from "../../../../../../queries/useGetOrderPublic.ts"; +import {eventCheckoutPath} from "../../../../../../utilites/urlHelper.ts"; +import { useMutation } from "@tanstack/react-query"; +import { orderClientPublic } from "../../../../../../api/order.client.ts"; + +declare global { + interface Window { + Razorpay: any; + } +} + +interface RazorpayPaymentMethodProps { + enabled: boolean; + setSubmitHandler: (submitHandler: () => () => Promise) => void; +} + +export const RazorpayPaymentMethod = ({enabled, setSubmitHandler}: RazorpayPaymentMethodProps) => { + const navigate = useNavigate(); + const {eventId, orderShortId} = useParams(); + const { + data: razorpayData, + isFetched: isRazorpayFetched, + error: razorpayOrderError + } = useCreateRazorpayOrder(eventId, orderShortId); + const {data: event} = useGetEventPublic(eventId); + const {data: order} = useGetOrderPublic(eventId, orderShortId, ['event']); + const [isLoading, setIsLoading] = useState(false); + + const verifyMutation = useMutation({ + mutationFn: (payload: { + razorpay_payment_id: string, + razorpay_order_id: string, + razorpay_signature: string, + }) => { + if (!eventId || !orderShortId) { + throw new Error('Missing event ID or order ID'); + } + return orderClientPublic.verifyRazorpayPayment(Number(eventId), orderShortId, payload); + }, + onSuccess: () => navigate(eventCheckoutPath(eventId, orderShortId, 'summary')) + }); + + const loadRazorpayScript = () => { + return new Promise((resolve, reject) => { + if (window.Razorpay) { + resolve(true); + return; + } + + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + script.async = true; + script.onload = () => resolve(true); + script.onerror = () => reject(new Error('Failed to load Razorpay script')); + document.body.appendChild(script); + }); + }; + + const handleRazorpayPayment = async () => { + if (!razorpayData || !order || !event) return; + + setIsLoading(true); + try { + await loadRazorpayScript(); + + const options = { + key: razorpayData.key_id, + amount: razorpayData.amount, + currency: razorpayData.currency, + name: event.title, + description: `Order ${order.short_id}`, + order_id: razorpayData.razorpay_order_id, + handler: async (response: any) => { + try { + await verifyMutation.mutate({ + razorpay_payment_id: response.razorpay_payment_id, + razorpay_order_id: response.razorpay_order_id, + razorpay_signature: response.razorpay_signature, + }); + + } catch (error) { + console.error('Payment verification error:', error); + } finally { + setIsLoading(false); + } + }, + prefill: { + name: `${order.first_name} ${order.last_name}`, + email: order.email, + contact: '', // Optional: Could collect phone number in earlier step + }, + notes: { + order_short_id: order.short_id, + event_id: eventId, + }, + theme: { + color: '#10B981', // Use theme accent color + }, + modal: { + ondismiss: () => { + setIsLoading(false); + }, + }, + }; + + const razorpayInstance = new window.Razorpay(options); + razorpayInstance.open(); + } catch (error) { + console.error('Razorpay payment error:', error); + setIsLoading(false); + // Handle error + } + }; + + useEffect(() => { + if (setSubmitHandler) { + setSubmitHandler(() => handleRazorpayPayment); + } + }, [setSubmitHandler, razorpayData, order, event]); + + if (!enabled) { + return ( + + + + ); + } + + if (razorpayOrderError && event) { + return ( + + + + ); + } + + if (!isRazorpayFetched) { + return ; + } + + return ( +
+

{t`Payment`}

+

+ {t`You will be redirected to Razorpay's secure payment page to complete your transaction.`} +

+ + {isLoading && } + + {/* Payment method details display */} +
+
+
+ + + +
+
+

{t`Secure Payment`}

+

+ {t`Powered by Razorpay`} +

+
+
+ + {razorpayData && ( +
+
+ {t`Order ID:`} + {order?.short_id} +
+
+ {t`Amount:`} + + {new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: razorpayData.currency + }).format(razorpayData.amount / 100)} + +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/routes/product-widget/Payment/index.tsx b/frontend/src/components/routes/product-widget/Payment/index.tsx index a5fe67c282..4d9915e29f 100644 --- a/frontend/src/components/routes/product-widget/Payment/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/index.tsx @@ -19,6 +19,7 @@ import {showError} from "../../../../utilites/notifications.tsx"; import {getConfig} from "../../../../utilites/config.ts"; import classes from "./Payment.module.scss"; import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts"; +import { RazorpayPaymentMethod } from "./PaymentMethods/Razorpay/index.tsx"; const Payment = () => { const navigate = useNavigate(); @@ -27,23 +28,26 @@ const Payment = () => { const {data: order, isFetched: isOrderFetched} = useGetOrderPublic(eventId, orderShortId, ['event']); const isLoading = !isOrderFetched; const [isPaymentLoading, setIsPaymentLoading] = useState(false); - const [activePaymentMethod, setActivePaymentMethod] = useState<'STRIPE' | 'OFFLINE' | null>(null); + const [activePaymentMethod, setActivePaymentMethod] = useState<'STRIPE' | 'RAZORPAY' | 'OFFLINE' | null>(null); const [submitHandler, setSubmitHandler] = useState<(() => Promise) | null>(null); const transitionOrderToOfflinePaymentMutation = useTransitionOrderToOfflinePaymentPublic(); const isStripeEnabled = event?.settings?.payment_providers?.includes('STRIPE'); + const isRazorpayEnabled = event?.settings?.payment_providers?.includes('RAZORPAY'); const isOfflineEnabled = event?.settings?.payment_providers?.includes('OFFLINE'); React.useEffect(() => { // Automatically set the first available payment method if (isStripeEnabled) { setActivePaymentMethod('STRIPE'); + } else if (isRazorpayEnabled) { + setActivePaymentMethod('RAZORPAY'); } else if (isOfflineEnabled) { setActivePaymentMethod('OFFLINE'); } else { setActivePaymentMethod(null); // No methods available } - }, [isStripeEnabled, isOfflineEnabled]); + }, [isStripeEnabled, isRazorpayEnabled, isOfflineEnabled]); React.useEffect(() => { // Scroll to top when payment page loads @@ -58,7 +62,7 @@ const Payment = () => { }; const handleSubmit = async () => { - if (activePaymentMethod === 'STRIPE') { + if (activePaymentMethod === 'STRIPE' || activePaymentMethod === 'RAZORPAY') { handleParentSubmit(); } else if (activePaymentMethod === 'OFFLINE') { setIsPaymentLoading(true); @@ -80,7 +84,7 @@ const Payment = () => { } }; - if (!isStripeEnabled && !isOfflineEnabled && isOrderFetched && isEventFetched) { + if (!isStripeEnabled && !isRazorpayEnabled && !isOfflineEnabled && isOrderFetched && isEventFetched) { return ( @@ -102,26 +106,34 @@ const Payment = () => { )} + {isRazorpayEnabled && ( +
+ +
+ )} + {isOfflineEnabled && (
)} - {(isStripeEnabled && isOfflineEnabled) && ( + {((isStripeEnabled || isRazorpayEnabled) && isOfflineEnabled) && (
{t`Payment method`}
- + {(isStripeEnabled || isRazorpayEnabled) && ( + + )}