From 93f1a0401a560f8ea7e878bfc81eab280feed95a Mon Sep 17 00:00:00 2001 From: Oness Date: Thu, 15 Jan 2026 08:26:42 +0000 Subject: [PATCH 01/41] 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 8eb53645c..46f42bce1 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 000000000..c0a5b3406 --- /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 723e98b90..e2492d7a0 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 000000000..3dda29370 --- /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 000000000..77e08ed0a --- /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 000000000..85935aa0c --- /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 e69cdad97..0508cf9d3 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 000000000..923a19976 --- /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 712839ee3..11fd05d50 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 000000000..2ea6036ea --- /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 000000000..e0b13cab0 --- /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 000000000..df78d9c56 --- /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 000000000..ee12b8af3 --- /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 000000000..9a2475f56 --- /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 000000000..501d5cb69 --- /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 000000000..ab2284676 --- /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 8dbd2ad25..f5f1b4ba2 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 c6c34f098..0c0913950 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 44f123a1e..d675543d7 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 000000000..ad1671b82 --- /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 c84c87fe9..e60d95a60 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 c54d8e5a0..75ad4cdb0 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 98bd27bab..6fc5cdf3c 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 000000000..790f6121b --- /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 a5fe67c28..4d9915e29 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) && ( + + )} + )} {(isStripeEnabled || isRazorpayEnabled) && ( )}
@@ -154,7 +164,7 @@ const Payment = () => { > {order?.is_payment_required ? ( - + {t`Pay`} {formatCurrency(order.total_gross, order.currency)} ) : t`Complete Payment`} From aa9c5e9690ffd80f9d303c1fbd965d4a6befe87f Mon Sep 17 00:00:00 2001 From: Oness Date: Mon, 23 Feb 2026 08:24:59 +0000 Subject: [PATCH 16/41] move DTO --- .../Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php | 2 +- .../Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php | 2 +- .../Payment/Razorpay/DTOs}/VerifyRazorpayPaymentDTO.php | 2 +- .../Payment/Razorpay/RazorpayPaymentVerificationService.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename backend/app/Services/{Application/Handlers/Order/DTO => Domain/Payment/Razorpay/DTOs}/VerifyRazorpayPaymentDTO.php (84%) diff --git a/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php index cd9ff926b..268572053 100644 --- a/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php +++ b/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php @@ -5,8 +5,8 @@ use HiEvents\Exceptions\Razorpay\PaymentVerificationFailedException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Order\VerifyRazorpayPaymentRequest; -use HiEvents\Services\Application\Handlers\Order\DTO\VerifyRazorpayPaymentDTO; use HiEvents\Services\Application\Handlers\Order\Payment\Razorpay\VerifyRazorpayPaymentHandler; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\VerifyRazorpayPaymentDTO; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php index 7469243c9..ccc244a73 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php @@ -20,8 +20,8 @@ use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; -use HiEvents\Services\Application\Handlers\Order\DTO\VerifyRazorpayPaymentDTO; use HiEvents\Services\Domain\Order\OrderApplicationFeeService; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\VerifyRazorpayPaymentDTO; use HiEvents\Services\Domain\Payment\Razorpay\RazorpayPaymentVerificationService; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; diff --git a/backend/app/Services/Application/Handlers/Order/DTO/VerifyRazorpayPaymentDTO.php b/backend/app/Services/Domain/Payment/Razorpay/DTOs/VerifyRazorpayPaymentDTO.php similarity index 84% rename from backend/app/Services/Application/Handlers/Order/DTO/VerifyRazorpayPaymentDTO.php rename to backend/app/Services/Domain/Payment/Razorpay/DTOs/VerifyRazorpayPaymentDTO.php index 3a9bb8573..89d90fb95 100644 --- a/backend/app/Services/Application/Handlers/Order/DTO/VerifyRazorpayPaymentDTO.php +++ b/backend/app/Services/Domain/Payment/Razorpay/DTOs/VerifyRazorpayPaymentDTO.php @@ -1,6 +1,6 @@ Date: Mon, 23 Feb 2026 12:12:29 +0000 Subject: [PATCH 17/41] webhook handler for order.paid implemented --- .../RazorpayIncomingWebhookAction.php | 1 - backend/app/Models/RazorpayOrder.php | 8 +- .../Razorpay/RazorpayWebhookHandler.php | 119 +++++++----- .../Razorpay/DTOs/RazorpayErrorDTO.php | 17 ++ .../Razorpay/DTOs/RazorpayOrderDTO.php | 21 +++ .../DTOs/RazorpayOrderPaidEventDTO.php | 17 ++ .../DTOs/RazorpayOrderPaidPayload.php | 16 ++ .../Razorpay/DTOs/RazorpayPaymentDTO.php | 28 +++ .../Razorpay/DTOs/RazorpayPaymentEventDTO.php | 17 ++ .../Razorpay/DTOs/RazorpayPaymentPayload.php | 15 ++ .../Razorpay/DTOs/RazorpayWebhookEnvelope.php | 46 +++++ .../RazorpayOrderPaidHandler.php | 175 ++++++++++++++++++ .../RazorpayPaymentAuthorizedHandler.php | 70 +++++++ .../RazorpayPaymentCapturedHandler.php | 100 +++++----- .../RazorpayPaymentFailedHandler.php | 83 +++++++++ ...14_074419_create_razorpay_orders_table.php | 18 +- 16 files changed, 641 insertions(+), 110 deletions(-) create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayErrorDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayOrderDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayOrderPaidEventDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayOrderPaidPayload.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayPaymentDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayPaymentEventDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayPaymentPayload.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayWebhookEnvelope.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php diff --git a/backend/app/Http/Actions/Common/Webhooks/RazorpayIncomingWebhookAction.php b/backend/app/Http/Actions/Common/Webhooks/RazorpayIncomingWebhookAction.php index b2850ef58..8744cb06f 100644 --- a/backend/app/Http/Actions/Common/Webhooks/RazorpayIncomingWebhookAction.php +++ b/backend/app/Http/Actions/Common/Webhooks/RazorpayIncomingWebhookAction.php @@ -3,7 +3,6 @@ namespace HiEvents\Http\Actions\Common\Webhooks; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Http\ResponseCodes; use HiEvents\Services\Application\Handlers\Order\Payment\Razorpay\RazorpayWebhookHandler; use Illuminate\Http\Request; use Illuminate\Http\Response; diff --git a/backend/app/Models/RazorpayOrder.php b/backend/app/Models/RazorpayOrder.php index 923a19976..dc50f141d 100644 --- a/backend/app/Models/RazorpayOrder.php +++ b/backend/app/Models/RazorpayOrder.php @@ -2,7 +2,6 @@ namespace HiEvents\Models; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class RazorpayOrder extends BaseModel @@ -14,14 +13,19 @@ class RazorpayOrder extends BaseModel 'razorpay_order_id', 'razorpay_payment_id', 'razorpay_signature', + 'method', + 'fee', + 'tax', 'amount', 'currency', 'receipt', - 'payment_status', + 'status', ]; protected $casts = [ 'amount' => 'integer', + 'fee' => 'integer', + 'tax' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php index 99b601b11..9e7de965f 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php @@ -2,112 +2,129 @@ namespace HiEvents\Services\Application\Handlers\Order\Payment\Razorpay; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayWebhookEnvelope; use HiEvents\Exceptions\Razorpay\InvalidSignatureException; use HiEvents\Services\Domain\Payment\Razorpay\RazorpayPaymentVerificationService; use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentCapturedHandler; -use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayRefundHandler; +use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayOrderPaidHandler; +// use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayRefundHandler; +use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentFailedHandler; +use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentAuthorizedHandler; use Illuminate\Cache\Repository; use Illuminate\Log\Logger; use JsonException; use Throwable; +use Spatie\LaravelData\Exceptions\CannotCreateData; class RazorpayWebhookHandler { private static array $validEvents = [ 'payment.captured', + 'order.paid', 'refund.processed', 'payment.failed', - 'order.paid', + 'payment.authorized', ]; public function __construct( private readonly RazorpayPaymentCapturedHandler $paymentCapturedHandler, - private readonly RazorpayRefundHandler $refundHandler, + private readonly RazorpayOrderPaidHandler $orderPaidHandler, + // private readonly RazorpayRefundHandler $refundHandler, + private readonly RazorpayPaymentFailedHandler $paymentFailedHandler, + private readonly RazorpayPaymentAuthorizedHandler $paymentAuthorizedHandler, private readonly RazorpayPaymentVerificationService $razorpayPaymentService, - private readonly Logger $logger, - private readonly Repository $cache, + private readonly Logger $logger, + private readonly Repository $cache, ) { } /** * @throws InvalidSignatureException * @throws JsonException + * @throws CannotCreateData * @throws Throwable */ public function handle(string $payload, string $signature): void { try { - // Verify webhook signature + // 1. Verify webhook signature if (!$this->razorpayPaymentService->verifyWebhookSignature($payload, $signature)) { throw new InvalidSignatureException(__('Invalid Razorpay webhook signature')); } + // 2. Decode JSON and create envelope DTO $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); - $event = $data['event'] ?? null; - $eventId = $data['id'] ?? null; + $envelope = RazorpayWebhookEnvelope::fromArray($data); + $event = $envelope->event; - if (!$event || !$eventId) { - $this->logger->error('Invalid Razorpay webhook payload', ['payload' => $payload]); + // 3. Validate event type + if (!in_array($event, self::$validEvents, true)) { + $this->logger->debug('Unsupported webhook event', ['event' => $event]); return; } - if (!in_array($event, self::$validEvents, true)) { - $this->logger->debug(__('Received a :event Razorpay event, which has no handler', [ - 'event' => $event, - ]), [ - 'event_id' => $eventId, - 'event_type' => $event, - ]); + // 4. Extract unique event ID for idempotency + $eventId = match ($event) { + 'payment.captured', 'payment.failed', 'payment.authorized' => $envelope->payload->payment->id, + 'order.paid' => $envelope->payload->order->id, + // 'refund.processed' => $envelope->payload->refund->id, + default => null, + }; + if (!$eventId) { + $this->logger->error('Could not extract event ID from payload', ['event' => $event]); return; } + // 5. Idempotency check if ($this->hasEventBeenHandled($eventId)) { $this->logger->debug('Razorpay webhook event already handled', [ 'event_id' => $eventId, 'type' => $event, - 'data' => $data, ]); - return; } - $this->logger->debug('Razorpay webhook received: ' . $event, $data); - - switch ($event) { - case 'payment.captured': - case 'order.paid': - $this->paymentCapturedHandler->handleEvent($data['payload']['payment']['entity']); - break; - case 'refund.processed': - $this->refundHandler->handleEvent($data['payload']['refund']['entity']); - break; - case 'payment.failed': - // Handle failed payments if needed - $this->logger->info('Razorpay payment failed', $data['payload']['payment']['entity']); - break; - } + $this->logger->debug('Processing Razorpay webhook', [ + 'event' => $event, + 'event_id' => $eventId, + ]); + // 6. Route to the appropriate handler based on event type + match ($event) { + 'payment.captured' => $this->paymentCapturedHandler->handleEvent($envelope->payload), + 'order.paid' => $this->orderPaidHandler->handleEvent($envelope->payload), + // 'refund.processed' => $this->refundHandler->handleEvent($envelope->payload), + 'payment.failed' => $this->paymentFailedHandler->handleEvent($envelope->payload), + 'payment.authorized' => $this->paymentAuthorizedHandler->handleEvent($envelope->payload), + default => $this->logger->debug('No handler for event', ['event' => $event]), + }; + + // 7. Mark event as handled $this->markEventAsHandled($eventId); - } catch (InvalidSignatureException $exception) { - $this->logger->error( - 'Unable to verify Razorpay webhook signature: ' . $exception->getMessage(), [ - 'payload' => $payload, - ] - ); - throw $exception; - } catch (JsonException $exception) { - $this->logger->error( - 'Invalid JSON in Razorpay webhook payload: ' . $exception->getMessage(), [ - 'payload' => $payload, - ] - ); - throw $exception; - } catch (Throwable $exception) { - $this->logger->error('Unhandled Razorpay webhook error: ' . $exception->getMessage(), [ + } catch (InvalidSignatureException $e) { + $this->logger->error('Signature verification failed', [ + 'error' => $e->getMessage(), + ]); + throw $e; + } catch (JsonException $e) { + $this->logger->error('Invalid JSON payload', [ + 'error' => $e->getMessage(), 'payload' => $payload, ]); - throw $exception; + throw $e; + } catch (CannotCreateData $e) { + $this->logger->error('Failed to create DTO from webhook payload', [ + 'error' => $e->getMessage(), + 'payload' => $payload, + ]); + throw $e; + } catch (Throwable $e) { + $this->logger->error('Unhandled exception processing webhook', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; } } diff --git a/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayErrorDTO.php b/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayErrorDTO.php new file mode 100644 index 000000000..751271667 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayErrorDTO.php @@ -0,0 +1,17 @@ + RazorpayOrderPaidPayload::from([ + 'order' => $payloadData['order']['entity'], + 'payment' => $payloadData['payment']['entity'], + ]), + 'payment.captured', 'payment.failed', 'payment.authorized' => RazorpayPaymentPayload::from([ + 'payment' => $payloadData['payment']['entity'], + ]), + // 'refund.processed' => RazorpayRefundPayload::from([ + // 'refund' => $payloadData['refund']['entity'], + // ]), + default => throw new InvalidArgumentException("Unknown event: {$event}"), + }; + + return new self( + entity: $data['entity'], + account_id: $data['account_id'], + event: $event, + payload: $payload, + created_at: $data['created_at'], + ); + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php new file mode 100644 index 000000000..93725ed31 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php @@ -0,0 +1,175 @@ +order; + $paymentEntity = $event->payment; + + // Use the Razorpay order ID as the idempotency key (or payment ID) + $idempotencyKey = 'razorpay_order_paid_' . $orderEntity->id; + + if ($this->cache->has($idempotencyKey)) { + $this->logger->info('Razorpay order.paid event already handled', [ + 'razorpay_order_id' => $orderEntity->id, + 'razorpay_payment_id' => $paymentEntity->id, + ]); + return; + } + + $this->databaseManager->transaction(function () use ($orderEntity, $paymentEntity) { + // Find local razorpay order record by the Razorpay order ID + $razorpayOrder = $this->razorpayOrdersRepository->findByRazorpayOrderId($orderEntity->id); + + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for order.paid webhook', [ + 'razorpay_order_id' => $orderEntity->id, + ]); + return; + } + + $localOrderId = $razorpayOrder->getOrderId(); + + // Load the full local order with items + $order = $this->orderRepository + ->loadRelation(new Relationship(OrderItemDomainObject::class)) + ->findById($localOrderId); + + if (!$order) { + $this->logger->warning('Local order not found for order.paid webhook', [ + 'local_order_id' => $localOrderId, + 'razorpay_order_id' => $orderEntity->id, + ]); + return; + } + + // Update the razorpay_orders record with payment details (all amounts in paise) + $this->razorpayOrdersRepository->updateByOrderId($localOrderId, [ + 'razorpay_payment_id' => $paymentEntity->id, + 'status' => $paymentEntity->status, + 'method' => $paymentEntity->method, + 'amount' => $paymentEntity->amount, + 'currency' => $paymentEntity->currency, + 'fee' => $paymentEntity->fee, + 'tax' => $paymentEntity->tax, + ]); + + // If order not already marked as paid, update its status and related entities + if ($order->getPaymentStatus() !== OrderPaymentStatus::PAYMENT_RECEIVED->name) { + $updatedOrder = $this->updateOrderStatuses($order); + + $this->updateAttendeeStatuses($updatedOrder); + $this->quantityUpdateService->updateQuantitiesFromOrder($updatedOrder); + $this->updateAffiliateSales($updatedOrder); + + OrderStatusChangedEvent::dispatch($updatedOrder); + + $this->domainEventDispatcherService->dispatch( + new OrderEvent( + type: DomainEventType::ORDER_CREATED, + orderId: $updatedOrder->getId() + ) + ); + } + + // Store application fee (fee is in paise) + $this->orderApplicationFeeService->createOrderApplicationFee( + orderId: $order->getId(), + applicationFeeAmountMinorUnit: $paymentEntity->fee ?? 0, + orderApplicationFeeStatus: \HiEvents\DomainObjects\Status\OrderApplicationFeeStatus::PAID, + paymentMethod: PaymentProviders::RAZORPAY, + currency: $paymentEntity->currency, + ); + + $this->logger->info('Razorpay order.paid webhook processed successfully', [ + 'razorpay_order_id' => $orderEntity->id, + 'razorpay_payment_id' => $paymentEntity->id, + 'local_order_id' => $order->getId(), + ]); + }); + + // Mark as handled after successful transaction + $this->cache->put($idempotencyKey, true, now()->addHours(24)); + } + + private function updateOrderStatuses(OrderDomainObject $order): OrderDomainObject + { + return $this->orderRepository + ->updateFromArray($order->getId(), [ + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->name, + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::PAYMENT_PROVIDER => PaymentProviders::RAZORPAY->value, + ]); + } + + private function updateAttendeeStatuses(OrderDomainObject $order): void + { + $this->attendeeRepository->updateWhere( + attributes: [ + 'status' => AttendeeStatus::ACTIVE->name, + ], + where: [ + 'order_id' => $order->getId(), + 'status' => AttendeeStatus::AWAITING_PAYMENT->name, + ], + ); + } + + private function updateAffiliateSales(OrderDomainObject $order): void + { + $orderArray = $order->toArray(); + $affiliateId = $orderArray['affiliate_id'] ?? null; + + if ($affiliateId) { + $this->affiliateRepository->incrementSales( + affiliateId: $affiliateId, + amount: $order->getTotalGross() + ); + } + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php new file mode 100644 index 000000000..351b701aa --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php @@ -0,0 +1,70 @@ +payment; + $idempotencyKey = 'razorpay_authorized_' . $paymentEntity->id; + + if ($this->cache->has($idempotencyKey)) { + $this->logger->info('Razorpay payment.authorized event already handled', [ + 'payment_id' => $paymentEntity->id, + ]); + return; + } + + $this->databaseManager->transaction(function () use ($paymentEntity) { + // Try to find by payment ID first, then by order ID + $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentEntity->id) + ?? $this->razorpayOrdersRepository->findByOrderId($paymentEntity->order_id); + + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for payment.authorized', [ + 'payment_id' => $paymentEntity->id, + 'order_id' => $paymentEntity->order_id, + ]); + return; + } + + // Update record with payment details (status will be 'authorized') + $this->razorpayOrdersRepository->updateByOrderId($razorpayOrder->getOrderId(), [ + 'razorpay_payment_id' => $paymentEntity->id, + 'status' => $paymentEntity->status, // 'authorized' + 'method' => $paymentEntity->method, + 'amount' => $paymentEntity->amount, + 'currency' => $paymentEntity->currency, + 'fee' => $paymentEntity->fee, + 'tax' => $paymentEntity->tax, + ]); + + $this->logger->info('Razorpay payment authorized recorded', [ + 'payment_id' => $paymentEntity->id, + 'order_id' => $razorpayOrder->getOrderId(), + ]); + }); + + $this->cache->put($idempotencyKey, true, now()->addHours(24)); + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php index a3df5a3da..74b82275f 100644 --- a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php @@ -16,6 +16,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; use HiEvents\Services\Domain\Order\OrderApplicationFeeService; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayPaymentEventDTO; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; @@ -44,31 +45,34 @@ public function __construct( /** * @throws Throwable */ - public function handleEvent(array $paymentData): void + // TODO: Change param type to accept payload + public function handleEvent(RazorpayPaymentEventDTO $event): void { - if ($this->isPaymentAlreadyHandled($paymentData['id'])) { + $paymentEntity = $event->payment; + + // Idempotency check: avoid processing the same payment twice + if ($this->isPaymentAlreadyHandled($paymentEntity->id)) { $this->logger->info('Razorpay payment already handled via webhook', [ - 'razorpay_payment_id' => $paymentData['id'], + 'razorpay_payment_id' => $paymentEntity->id, ]); return; } - $this->databaseManager->transaction(function () use ($paymentData) { - // Find by razorpay_payment_id (stored in razorpay_order table) - $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentData['id']); + $this->databaseManager->transaction(function () use ($paymentEntity) { + // Find the local razorpay order record by the Razorpay payment ID + $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentEntity->id); if (!$razorpayOrder) { $this->logger->warning('Razorpay order not found for webhook', [ - 'razorpay_payment_id' => $paymentData['id'], + 'razorpay_payment_id' => $paymentEntity->id, ]); return; } - // Get the order ID from the razorpay order object - // Try toArray() method first, then check for getters + // Extract the local order ID from the razorpay order record $razorpayOrderArray = $razorpayOrder->toArray(); $orderId = $razorpayOrderArray['order_id'] ?? null; - + if (!$orderId) { $this->logger->error('Could not get order ID from Razorpay order', [ 'razorpay_order' => $razorpayOrderArray, @@ -76,44 +80,43 @@ public function handleEvent(array $paymentData): void return; } - // Load the order with items + // Load the full order with its items $order = $this->orderRepository ->loadRelation(new Relationship(OrderItemDomainObject::class)) ->findById($orderId); if (!$order) { $this->logger->warning('Order not found for Razorpay payment', [ - 'razorpay_payment_id' => $paymentData['id'], + 'razorpay_payment_id' => $paymentEntity->id, 'order_id' => $orderId, ]); return; } - // Update Razorpay order info with webhook data + // Update the razorpay_orders record with webhook data (all amounts in paise) $this->razorpayOrdersRepository->updateByOrderId($orderId, [ - 'razorpay_payment_id' => $paymentData['id'], - 'status' => $paymentData['status'], - 'method' => $paymentData['method'], - 'amount' => $paymentData['amount'] / 100, // Convert from paise to rupees - 'currency' => $paymentData['currency'], - 'fee' => $paymentData['fee'] ?? 0, - 'tax' => $paymentData['tax'] ?? 0, + 'razorpay_payment_id' => $paymentEntity->id, + 'status' => $paymentEntity->status, + 'method' => $paymentEntity->method, + 'amount' => $paymentEntity->amount, + 'currency' => $paymentEntity->currency, + 'fee' => $paymentEntity->fee, + 'tax' => $paymentEntity->tax, ]); - // Update order if not already completed (in case callback failed) - // Get payment status from order array + // If the order is not already marked as paid, update its status and related entities $orderArray = $order->toArray(); - $paymentStatus = $orderArray['payment_status'] ?? null; - - if ($paymentStatus !== OrderPaymentStatus::PAYMENT_RECEIVED->name) { + $currentPaymentStatus = $orderArray['payment_status'] ?? null; + + if ($currentPaymentStatus !== OrderPaymentStatus::PAYMENT_RECEIVED->name) { $updatedOrder = $this->updateOrderStatuses($order); - + $this->updateAttendeeStatuses($updatedOrder); $this->quantityUpdateService->updateQuantitiesFromOrder($updatedOrder); $this->updateAffiliateSales($updatedOrder); - + OrderStatusChangedEvent::dispatch($updatedOrder); - + $this->domainEventDispatcherService->dispatch( new OrderEvent( type: DomainEventType::ORDER_CREATED, @@ -122,10 +125,17 @@ public function handleEvent(array $paymentData): void ); } - // Store application fee - $this->storeApplicationFeePayment($order, $paymentData); - - $this->markPaymentAsHandled($paymentData['id'], $order); + // Store the application fee (fee is already in paise) + $this->orderApplicationFeeService->createOrderApplicationFee( + orderId: $order->getId(), + applicationFeeAmountMinorUnit: $paymentEntity->fee ?? 0, + orderApplicationFeeStatus: \HiEvents\DomainObjects\Status\OrderApplicationFeeStatus::PAID, + paymentMethod: PaymentProviders::RAZORPAY, + currency: $paymentEntity->currency, + ); + + // Final idempotency marker + $this->markPaymentAsHandled($paymentEntity->id, $order); }); } @@ -134,7 +144,7 @@ private function updateOrderStatuses(OrderDomainObject $order): OrderDomainObjec return $this->orderRepository ->updateFromArray($order->getId(), [ OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->name, - OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, OrderDomainObjectAbstract::PAYMENT_PROVIDER => PaymentProviders::RAZORPAY->value, ]); } @@ -147,17 +157,16 @@ private function updateAttendeeStatuses(OrderDomainObject $order): void ], where: [ 'order_id' => $order->getId(), - 'status' => AttendeeStatus::AWAITING_PAYMENT->name, + 'status' => AttendeeStatus::AWAITING_PAYMENT->name, ], ); } private function updateAffiliateSales(OrderDomainObject $order): void { - // Get affiliate ID from order array $orderArray = $order->toArray(); $affiliateId = $orderArray['affiliate_id'] ?? null; - + if ($affiliateId) { $this->affiliateRepository->incrementSales( affiliateId: $affiliateId, @@ -166,19 +175,6 @@ private function updateAffiliateSales(OrderDomainObject $order): void } } - private function storeApplicationFeePayment(OrderDomainObject $order, array $paymentData): void - { - $feeAmount = $paymentData['fee'] ?? 0; // Fee in paise - - $this->orderApplicationFeeService->createOrderApplicationFee( - orderId: $order->getId(), - applicationFeeAmountMinorUnit: $feeAmount, - orderApplicationFeeStatus: \HiEvents\DomainObjects\Status\OrderApplicationFeeStatus::PAID, - paymentMethod: PaymentProviders::RAZORPAY, - currency: $order->getCurrency(), - ); - } - private function isPaymentAlreadyHandled(string $paymentId): bool { return $this->cache->has('razorpay_webhook_payment_' . $paymentId); @@ -188,9 +184,9 @@ private function markPaymentAsHandled(string $paymentId, OrderDomainObject $orde { $this->logger->info('Razorpay payment captured via webhook', [ 'razorpay_payment_id' => $paymentId, - 'order_id' => $order->getId(), - 'amount' => $order->getTotalGross(), - 'currency' => $order->getCurrency(), + 'order_id' => $order->getId(), + 'amount' => $order->getTotalGross(), + 'currency' => $order->getCurrency(), ]); $this->cache->put('razorpay_webhook_payment_' . $paymentId, true, now()->addHours(24)); diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php new file mode 100644 index 000000000..0025aeeba --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php @@ -0,0 +1,83 @@ +payment; + $idempotencyKey = 'razorpay_failed_' . $paymentEntity->id; + + if ($this->cache->has($idempotencyKey)) { + $this->logger->info('Razorpay payment.failed event already handled', [ + 'payment_id' => $paymentEntity->id, + ]); + return; + } + + $this->databaseManager->transaction(function () use ($paymentEntity) { + // Try to find by payment ID first (if this payment ID is already stored) + // If not found, fallback to order ID (because payment ID might not be stored yet) + $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentEntity->id) + ?? $this->razorpayOrdersRepository->findByOrderId($paymentEntity->order_id); + + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for payment.failed', [ + 'payment_id' => $paymentEntity->id, + 'order_id' => $paymentEntity->order_id, + ]); + return; + } + + // Prepare update data including error details if available + $updateData = [ + 'razorpay_payment_id' => $paymentEntity->id, + 'status' => $paymentEntity->status, // 'failed' + 'method' => $paymentEntity->method, + 'amount' => $paymentEntity->amount, + 'currency' => $paymentEntity->currency, + 'fee' => $paymentEntity->fee, + 'tax' => $paymentEntity->tax, + ]; + + // Add error details if present + if ($paymentEntity->error) { + $updateData['failure_reason'] = $paymentEntity->error->description; + $updateData['error_code'] = $paymentEntity->error->code; + } + + $this->razorpayOrdersRepository->updateByOrderId($razorpayOrder->getOrderId(), $updateData); + + // IMPORTANT: Do NOT change the order's payment status to failed. + // As per documentation, a failed payment may later be captured. + // The order remains in its current state (e.g., awaiting payment). + + $this->logger->info('Razorpay payment failed recorded', [ + 'payment_id' => $paymentEntity->id, + 'order_id' => $razorpayOrder->getOrderId(), + 'failure_reason' => $paymentEntity->error?->description, + ]); + }); + + $this->cache->put($idempotencyKey, true, now()->addHours(24)); + } +} \ No newline at end of file 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 index ad1671b82..bf4fc008e 100644 --- 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 @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { /** * Run the migrations. */ @@ -17,13 +16,24 @@ public function up(): void $table->string('razorpay_order_id')->unique(); $table->string('razorpay_payment_id')->nullable(); $table->string('razorpay_signature')->nullable(); + + $table->string('method')->nullable(); + $table->integer('fee')->nullable()->comment('Fee in paise'); + $table->integer('tax')->nullable()->comment('Tax in paise'); + $table->integer('amount'); $table->string('currency', 3); $table->string('receipt')->nullable(); - $table->string('payment_status')->default('created'); + + $table->string('status')->default('created'); + + $table->string('failure_reason')->nullable()->after('tax'); + $table->string('error_code')->nullable()->after('failure_reason'); + $table->timestamps(); - + $table->index(['order_id']); + $table->index('status'); }); } From 8f41af1d576848c9d1affd535d90090f4c575035 Mon Sep 17 00:00:00 2001 From: Oness Date: Tue, 24 Feb 2026 06:59:03 +0000 Subject: [PATCH 18/41] implemented webhook for payment.authorized, payment.captured, payment.failed --- .../Payment/Razorpay/VerifyRazorpayPaymentHandler.php | 2 +- .../Razorpay/EventHandlers/RazorpayOrderPaidHandler.php | 1 - .../EventHandlers/RazorpayPaymentAuthorizedHandler.php | 5 ++--- .../EventHandlers/RazorpayPaymentCapturedHandler.php | 5 ++--- .../EventHandlers/RazorpayPaymentFailedHandler.php | 7 +++---- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php index ccc244a73..49d8d982a 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php @@ -96,7 +96,7 @@ public function handle(string $orderShortId, VerifyRazorpayPaymentDTO $verifyRaz $this->razorpayOrdersRepository->updateByOrderId($order->getId(), [ 'razorpay_payment_id' => $verifyRazorpayPaymentData->razorpay_payment_id, 'razorpay_signature' => $verifyRazorpayPaymentData->razorpay_signature, - 'payment_status' => 'captured', + 'status' => 'captured', ]); // Fetch complete payment details from Razorpay API diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php index 93725ed31..77c7901e2 100644 --- a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandler.php @@ -16,7 +16,6 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; use HiEvents\Services\Domain\Order\OrderApplicationFeeService; -use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayOrderPaidEventDTO; use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayOrderPaidPayload; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php index 351b701aa..77c7825ce 100644 --- a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php @@ -3,7 +3,7 @@ namespace HiEvents\Services\Domain\Payment\Razorpay\EventHandlers; use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; -use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayPaymentEventDTO; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayPaymentPayload; use Illuminate\Cache\Repository; use Illuminate\Database\DatabaseManager; use Illuminate\Log\Logger; @@ -22,8 +22,7 @@ public function __construct( /** * @throws Throwable */ - // TODO: Change param type to accept payload - public function handleEvent(RazorpayPaymentEventDTO $event): void + public function handleEvent(RazorpayPaymentPayload $event): void { $paymentEntity = $event->payment; $idempotencyKey = 'razorpay_authorized_' . $paymentEntity->id; diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php index 74b82275f..50ed4fbac 100644 --- a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php @@ -16,7 +16,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; use HiEvents\Services\Domain\Order\OrderApplicationFeeService; -use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayPaymentEventDTO; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayPaymentPayload; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; @@ -45,8 +45,7 @@ public function __construct( /** * @throws Throwable */ - // TODO: Change param type to accept payload - public function handleEvent(RazorpayPaymentEventDTO $event): void + public function handleEvent(RazorpayPaymentPayload $event): void { $paymentEntity = $event->payment; diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php index 0025aeeba..823c1f4c3 100644 --- a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php @@ -3,7 +3,7 @@ namespace HiEvents\Services\Domain\Payment\Razorpay\EventHandlers; use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; -use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayPaymentEventDTO; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayPaymentPayload; use Illuminate\Cache\Repository; use Illuminate\Database\DatabaseManager; use Illuminate\Log\Logger; @@ -21,8 +21,7 @@ public function __construct( /** * @throws Throwable */ - // TODO: Change param type to accept payload - public function handleEvent(RazorpayPaymentEventDTO $event): void + public function handleEvent(RazorpayPaymentPayload $event): void { $paymentEntity = $event->payment; $idempotencyKey = 'razorpay_failed_' . $paymentEntity->id; @@ -38,7 +37,7 @@ public function handleEvent(RazorpayPaymentEventDTO $event): void // Try to find by payment ID first (if this payment ID is already stored) // If not found, fallback to order ID (because payment ID might not be stored yet) $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentEntity->id) - ?? $this->razorpayOrdersRepository->findByOrderId($paymentEntity->order_id); + ?? $this->razorpayOrdersRepository->findByRazorpayOrderId($paymentEntity->order_id); if (!$razorpayOrder) { $this->logger->warning('Razorpay order not found for payment.failed', [ From 653a33a926c56abcf9f5e79b0cfe8a87d0601343 Mon Sep 17 00:00:00 2001 From: Oness Date: Tue, 24 Feb 2026 08:06:16 +0000 Subject: [PATCH 19/41] implemented refund.processed event in razorpay webhook --- .../Status/OrderPaymentStatus.php | 4 + .../Eloquent/OrderRefundRepository.php | 5 + .../OrderRefundRepositoryInterface.php | 2 +- .../Razorpay/RazorpayWebhookHandler.php | 8 +- .../Razorpay/DTOs/RazorpayRefundDTO.php | 22 ++ .../Razorpay/DTOs/RazorpayRefundPayload.php | 12 + .../Razorpay/DTOs/RazorpayWebhookEnvelope.php | 8 +- .../EventHandlers/RazorpayRefundHandler.php | 213 +++++++----------- 8 files changed, 129 insertions(+), 145 deletions(-) create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayRefundDTO.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayRefundPayload.php diff --git a/backend/app/DomainObjects/Status/OrderPaymentStatus.php b/backend/app/DomainObjects/Status/OrderPaymentStatus.php index 60dc33397..ed89d4af6 100644 --- a/backend/app/DomainObjects/Status/OrderPaymentStatus.php +++ b/backend/app/DomainObjects/Status/OrderPaymentStatus.php @@ -9,5 +9,9 @@ enum OrderPaymentStatus case AWAITING_OFFLINE_PAYMENT; case PAYMENT_FAILED; case PAYMENT_RECEIVED; + + case REFUNDED; + + case PARTIALLY_REFUNDED; } diff --git a/backend/app/Repository/Eloquent/OrderRefundRepository.php b/backend/app/Repository/Eloquent/OrderRefundRepository.php index 4e81d0963..c5cdadf83 100644 --- a/backend/app/Repository/Eloquent/OrderRefundRepository.php +++ b/backend/app/Repository/Eloquent/OrderRefundRepository.php @@ -17,4 +17,9 @@ public function getDomainObject(): string { return OrderRefundDomainObject::class; } + + public function getTotalRefundedForOrder(int $orderId): float + { + return (float) $this->model->where('order_id', $orderId)->sum('amount'); + } } diff --git a/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php index 0c475af43..77183ddff 100644 --- a/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php @@ -9,5 +9,5 @@ */ interface OrderRefundRepositoryInterface extends RepositoryInterface { - + public function getTotalRefundedForOrder(int $orderId): float; } diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php index 9e7de965f..9564bb52e 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php @@ -7,7 +7,7 @@ use HiEvents\Services\Domain\Payment\Razorpay\RazorpayPaymentVerificationService; use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentCapturedHandler; use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayOrderPaidHandler; -// use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayRefundHandler; +use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayRefundHandler; use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentFailedHandler; use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentAuthorizedHandler; use Illuminate\Cache\Repository; @@ -29,7 +29,7 @@ class RazorpayWebhookHandler public function __construct( private readonly RazorpayPaymentCapturedHandler $paymentCapturedHandler, private readonly RazorpayOrderPaidHandler $orderPaidHandler, - // private readonly RazorpayRefundHandler $refundHandler, + private readonly RazorpayRefundHandler $refundHandler, private readonly RazorpayPaymentFailedHandler $paymentFailedHandler, private readonly RazorpayPaymentAuthorizedHandler $paymentAuthorizedHandler, private readonly RazorpayPaymentVerificationService $razorpayPaymentService, @@ -67,7 +67,7 @@ public function handle(string $payload, string $signature): void $eventId = match ($event) { 'payment.captured', 'payment.failed', 'payment.authorized' => $envelope->payload->payment->id, 'order.paid' => $envelope->payload->order->id, - // 'refund.processed' => $envelope->payload->refund->id, + 'refund.processed' => $envelope->payload->refund->id, default => null, }; @@ -94,7 +94,7 @@ public function handle(string $payload, string $signature): void match ($event) { 'payment.captured' => $this->paymentCapturedHandler->handleEvent($envelope->payload), 'order.paid' => $this->orderPaidHandler->handleEvent($envelope->payload), - // 'refund.processed' => $this->refundHandler->handleEvent($envelope->payload), + 'refund.processed' => $this->refundHandler->handleEvent($envelope->payload), 'payment.failed' => $this->paymentFailedHandler->handleEvent($envelope->payload), 'payment.authorized' => $this->paymentAuthorizedHandler->handleEvent($envelope->payload), default => $this->logger->debug('No handler for event', ['event' => $event]), diff --git a/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayRefundDTO.php b/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayRefundDTO.php new file mode 100644 index 000000000..802a3b33a --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayRefundDTO.php @@ -0,0 +1,22 @@ + RazorpayPaymentPayload::from([ 'payment' => $payloadData['payment']['entity'], ]), - // 'refund.processed' => RazorpayRefundPayload::from([ - // 'refund' => $payloadData['refund']['entity'], - // ]), + 'refund.processed' => RazorpayRefundPayload::from([ + 'refund' => $payloadData['refund']['entity'], + ]), default => throw new InvalidArgumentException("Unknown event: {$event}"), }; diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandler.php index 3261c61d2..b7689b626 100644 --- a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandler.php +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandler.php @@ -2,18 +2,15 @@ namespace HiEvents\Services\Domain\Payment\Razorpay\EventHandlers; -use HiEvents\DomainObjects\Enums\PaymentProviders; -use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; +use HiEvents\Services\Domain\Payment\Razorpay\DTOs\RazorpayRefundPayload; +use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\OrderDomainObject; -use HiEvents\DomainObjects\Status\OrderRefundStatus; -use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; +use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; -use HiEvents\Services\Domain\EventStatistics\EventStatisticsRefundService; -use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; -use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; -use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; -use HiEvents\Values\MoneyValue; +use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; +use Illuminate\Cache\Repository; use Illuminate\Database\DatabaseManager; use Illuminate\Log\Logger; use Throwable; @@ -21,166 +18,110 @@ class RazorpayRefundHandler { public function __construct( - private readonly OrderRepositoryInterface $orderRepository, + private readonly OrderRepositoryInterface $orderRepository, private readonly RazorpayOrdersRepositoryInterface $razorpayOrdersRepository, - private readonly Logger $logger, - private readonly DatabaseManager $databaseManager, - private readonly EventStatisticsRefundService $eventStatisticsRefundService, - private readonly OrderRefundRepositoryInterface $orderRefundRepository, - private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly OrderRefundRepositoryInterface $refundRepository, + private readonly DatabaseManager $databaseManager, + private readonly Logger $logger, + private readonly Repository $cache, ) { } /** * @throws Throwable */ - public function handleEvent(array $refundData): void + public function handleEvent(RazorpayRefundPayload $payload): void { - $this->databaseManager->transaction(function () use ($refundData) { - // Find Razorpay order by payment_id - $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($refundData['payment_id']); + $refundEntity = $payload->refund; + $paymentId = $refundEntity->payment_id; - if (!$razorpayOrder) { - $this->logger->warning('Razorpay order not found for refund', [ - 'refund_id' => $refundData['id'], - 'payment_id' => $refundData['payment_id'], - ]); - return; - } + // Idempotency key based on refund ID + $idempotencyKey = 'razorpay_refund_' . $refundEntity->id; - $existingRefund = $this->orderRefundRepository->findFirstWhere([ - 'refund_id' => $refundData['id'], + if ($this->cache->has($idempotencyKey)) { + $this->logger->info('Razorpay refund event already handled', [ + 'refund_id' => $refundEntity->id, + 'payment_id' => $paymentId, ]); + return; + } - if ($existingRefund) { - $this->logger->info(__('Refund already processed'), [ - 'refund_id' => $refundData['id'], - 'payment_id' => $refundData['payment_id'], - ]); - return; - } + $this->databaseManager->transaction(function () use ($refundEntity, $paymentId) { + // 1. Find the Razorpay order record by payment ID + $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentId); - // Get order ID from razorpay order array - $razorpayOrderArray = $razorpayOrder->toArray(); - $orderId = $razorpayOrderArray['order_id'] ?? null; - - if (!$orderId) { - $this->logger->error('Could not get order ID from Razorpay order for refund', [ - 'razorpay_order' => $razorpayOrderArray, - 'refund_data' => $refundData, + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for refund webhook', [ + 'payment_id' => $paymentId, + 'refund_id' => $refundEntity->id, ]); return; } - $order = $this->orderRepository->findById($orderId); + $localOrderId = $razorpayOrder->getOrderId(); + + // 2. Load the full order with items + $order = $this->orderRepository + ->loadRelation(new Relationship(OrderItemDomainObject::class)) + ->findById($localOrderId); - if ($refundData['status'] !== 'processed') { - $this->handleFailure($refundData, $order); + if (!$order) { + $this->logger->warning('Local order not found for refund webhook', [ + 'local_order_id' => $localOrderId, + 'refund_id' => $refundEntity->id, + ]); return; } - // Convert from paise to rupees - $refundedAmount = $refundData['amount'] / 100; - - // Get order ID from order array - $orderArray = $order->toArray(); - $orderIdFromOrder = $orderArray['id'] ?? $orderId; - - $this->updateOrderRefundedAmount($orderIdFromOrder, $refundedAmount); - $this->updateOrderStatus($order, $refundedAmount); - - // Update event statistics - $this->updateEventStatistics($order, $refundedAmount, $refundData['currency']); - - $this->createOrderRefund($refundData, $order, $refundedAmount, $orderIdFromOrder); - - $this->logger->info(__('Razorpay refund successful'), [ - 'order_id' => $orderIdFromOrder, - 'refunded_amount' => $refundedAmount, - 'currency' => $refundData['currency'], - 'refund_id' => $refundData['id'], + // 3. Store refund details in order_refunds table using repository + $refundAmountInRupees = $refundEntity->amount / 100; // Convert paise to rupees + $this->refundRepository->create([ + 'order_id' => $order->getId(), + 'payment_provider' => 'razorpay', + 'refund_id' => $refundEntity->id, + 'amount' => $refundAmountInRupees, + 'currency' => $refundEntity->currency, + 'status' => $refundEntity->status, + 'reason' => $refundEntity->notes['reason'] ?? null, + 'metadata' => [ + 'razorpay_refund' => $refundEntity->toArray(), + ], ]); - $this->domainEventDispatcherService->dispatch( - new OrderEvent( - type: DomainEventType::ORDER_REFUNDED, - orderId: $orderIdFromOrder - ), - ); + // 4. Update order payment status based on total refunded amount + $this->updateOrderPaymentStatus($order); + + $this->logger->info('Razorpay refund processed successfully', [ + 'refund_id' => $refundEntity->id, + 'payment_id' => $paymentId, + 'order_id' => $order->getId(), + 'amount' => $refundAmountInRupees, + 'status' => $refundEntity->status, + ]); }); - } - private function updateEventStatistics(OrderDomainObject $order, float $amount, string $currency): void - { - // Convert to minor units (paise) - $amountMinor = $amount * 100; - $moneyValue = MoneyValue::fromMinorUnit($amountMinor, $currency); - $this->eventStatisticsRefundService->updateForRefund($order, $moneyValue); + // Mark as handled after successful transaction + $this->cache->put($idempotencyKey, true, now()->addHours(24)); } - private function updateOrderRefundedAmount(int $orderId, float $refundedAmount): void + private function updateOrderPaymentStatus(OrderDomainObject $order): void { - $this->orderRepository->increment( - id: $orderId, - column: OrderDomainObjectAbstract::TOTAL_REFUNDED, - amount: $refundedAmount - ); - } + // Get total refunded amount for this order using repository method + $totalRefunded = $this->refundRepository->getTotalRefundedForOrder($order->getId()); + $orderTotal = $order->getTotalGross(); // Assume returns in rupees - private function updateOrderStatus(OrderDomainObject $order, float $refundedAmount): void - { - // Get order array and extract ID - $orderArray = $order->toArray(); - $orderId = $orderArray['id'] ?? null; - - if (!$orderId) { - $this->logger->error('Could not get ID from order for status update'); - return; + if ($totalRefunded <= 0) { + return; // No change if no refunds } - // Get total refunded amount from order array - $totalRefunded = $orderArray['total_refunded'] ?? 0; - - // Get total gross from order array - $totalGross = $orderArray['total_gross'] ?? 0; - - $status = $refundedAmount + $totalRefunded >= $totalGross - ? OrderRefundStatus::REFUNDED->name - : OrderRefundStatus::PARTIALLY_REFUNDED->name; - - $this->orderRepository->updateFromArray($orderId, [ - OrderDomainObjectAbstract::REFUND_STATUS => $status, - ]); - } - - private function handleFailure(array $refundData, OrderDomainObject $order): void - { - // Get order ID from order array - $orderArray = $order->toArray(); - $orderId = $orderArray['id'] ?? null; - - if (!$orderId) { - $this->logger->error('Could not get ID from order for failure handling'); - return; + if ($totalRefunded >= $orderTotal) { + $newStatus = OrderPaymentStatus::REFUNDED->name; + } else { + $newStatus = OrderPaymentStatus::PARTIALLY_REFUNDED->name; } - $this->orderRepository->updateFromArray($orderId, [ - OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::REFUND_FAILED->name, - ]); - - $this->logger->error(__('Failed to process Razorpay refund'), $refundData); - } - - private function createOrderRefund(array $refundData, OrderDomainObject $order, float $refundedAmount, int $orderId): void - { - $this->orderRefundRepository->create([ - 'order_id' => $orderId, - 'payment_provider' => PaymentProviders::RAZORPAY->value, - 'refund_id' => $refundData['id'], - 'amount' => $refundedAmount, - 'currency' => $refundData['currency'], - 'status' => $refundData['status'], - 'metadata' => $refundData, + $this->orderRepository->updateFromArray($order->getId(), [ + 'payment_status' => $newStatus, ]); } } \ No newline at end of file From 659f0c17a196d9099b1c12b166242dc7c1549d82 Mon Sep 17 00:00:00 2001 From: Oness Date: Tue, 24 Feb 2026 09:18:39 +0000 Subject: [PATCH 20/41] fixed conditional bug razorpay --- frontend/src/components/routes/product-widget/Payment/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/routes/product-widget/Payment/index.tsx b/frontend/src/components/routes/product-widget/Payment/index.tsx index 49d952793..d03d6c33f 100644 --- a/frontend/src/components/routes/product-widget/Payment/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/index.tsx @@ -134,7 +134,7 @@ const Payment = () => { {t`Stripe`} )} - {(isStripeEnabled || isRazorpayEnabled) && ( + {isRazorpayEnabled && (