diff --git a/backend/.env.example b/backend/.env.example index 12971f17a..b0f5fa11e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,6 +18,12 @@ STRIPE_PUBLIC_KEY= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +RAZORPAY_KEY_ID= +RAZORPAY_KEY_SECRET= +RAZORPAY_WEBHOOK_SECRET= +RAZORPAY_APPLICATION_FEE_ENABLED= +RAZORPAY_PLATFORM_ACCOUNT_ID= + CORS_ALLOWED_ORIGINS=* LOG_CHANNEL=stderr 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..afe730cb1 --- /dev/null +++ b/backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php @@ -0,0 +1,244 @@ + $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, + 'method' => $this->method ?? null, + 'fee' => $this->fee ?? null, + 'tax' => $this->tax ?? null, + 'amount' => $this->amount ?? null, + 'currency' => $this->currency ?? null, + 'receipt' => $this->receipt ?? null, + 'status' => $this->status ?? null, + 'failure_reason' => $this->failure_reason ?? null, + 'error_code' => $this->error_code ?? 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 setMethod(?string $method): self + { + $this->method = $method; + return $this; + } + + public function getMethod(): ?string + { + return $this->method; + } + + public function setFee(?int $fee): self + { + $this->fee = $fee; + return $this; + } + + public function getFee(): ?int + { + return $this->fee; + } + + public function setTax(?int $tax): self + { + $this->tax = $tax; + return $this; + } + + public function getTax(): ?int + { + return $this->tax; + } + + 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 setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setFailureReason(?string $failure_reason): self + { + $this->failure_reason = $failure_reason; + return $this; + } + + public function getFailureReason(): ?string + { + return $this->failure_reason; + } + + public function setErrorCode(?string $error_code): self + { + $this->error_code = $error_code; + return $this; + } + + public function getErrorCode(): ?string + { + return $this->error_code; + } + + 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/Generated/RefundAttemptDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/RefundAttemptDomainObjectAbstract.php new file mode 100644 index 000000000..1e96fc44b --- /dev/null +++ b/backend/app/DomainObjects/Generated/RefundAttemptDomainObjectAbstract.php @@ -0,0 +1,160 @@ + $this->id ?? null, + 'idempotency_key' => $this->idempotency_key ?? null, + 'payment_type' => $this->payment_type ?? null, + 'payment_id' => $this->payment_id ?? null, + 'status' => $this->status ?? null, + 'request_data' => $this->request_data ?? null, + 'response_data' => $this->response_data ?? null, + 'attempts' => $this->attempts ?? 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 setIdempotencyKey(string $idempotency_key): self + { + $this->idempotency_key = $idempotency_key; + return $this; + } + + public function getIdempotencyKey(): string + { + return $this->idempotency_key; + } + + public function setPaymentType(string $payment_type): self + { + $this->payment_type = $payment_type; + return $this; + } + + public function getPaymentType(): string + { + return $this->payment_type; + } + + public function setPaymentId(int $payment_id): self + { + $this->payment_id = $payment_id; + return $this; + } + + public function getPaymentId(): int + { + return $this->payment_id; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setRequestData(array|string|null $request_data): self + { + $this->request_data = $request_data; + return $this; + } + + public function getRequestData(): array|string|null + { + return $this->request_data; + } + + public function setResponseData(array|string|null $response_data): self + { + $this->response_data = $response_data; + return $this; + } + + public function getResponseData(): array|string|null + { + return $this->response_data; + } + + public function setAttempts(int $attempts): self + { + $this->attempts = $attempts; + return $this; + } + + public function getAttempts(): int + { + return $this->attempts; + } + + 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/Generated/WebhookDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php index 660f7a66f..8e301915f 100644 --- a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php @@ -13,8 +13,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const ID = 'id'; final public const USER_ID = 'user_id'; final public const EVENT_ID = 'event_id'; - final public const ORGANIZER_ID = 'organizer_id'; final public const ACCOUNT_ID = 'account_id'; + final public const ORGANIZER_ID = 'organizer_id'; final public const URL = 'url'; final public const EVENT_TYPES = 'event_types'; final public const LAST_RESPONSE_CODE = 'last_response_code'; @@ -29,8 +29,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected int $id; protected int $user_id; protected ?int $event_id = null; - protected ?int $organizer_id = null; protected int $account_id; + protected ?int $organizer_id = null; protected string $url; protected array|string $event_types; protected ?int $last_response_code = null; @@ -48,8 +48,8 @@ public function toArray(): array 'id' => $this->id ?? null, 'user_id' => $this->user_id ?? null, 'event_id' => $this->event_id ?? null, - 'organizer_id' => $this->organizer_id ?? null, 'account_id' => $this->account_id ?? null, + 'organizer_id' => $this->organizer_id ?? null, 'url' => $this->url ?? null, 'event_types' => $this->event_types ?? null, 'last_response_code' => $this->last_response_code ?? null, @@ -96,26 +96,26 @@ public function getEventId(): ?int return $this->event_id; } - public function setOrganizerId(?int $organizer_id): self + public function setAccountId(int $account_id): self { - $this->organizer_id = $organizer_id; + $this->account_id = $account_id; return $this; } - public function getOrganizerId(): ?int + public function getAccountId(): int { - return $this->organizer_id; + return $this->account_id; } - public function setAccountId(int $account_id): self + public function setOrganizerId(?int $organizer_id): self { - $this->account_id = $account_id; + $this->organizer_id = $organizer_id; return $this; } - public function getAccountId(): int + public function getOrganizerId(): ?int { - return $this->account_id; + return $this->organizer_id; } public function setUrl(string $url): self diff --git a/backend/app/DomainObjects/OrderDomainObject.php b/backend/app/DomainObjects/OrderDomainObject.php index b86ab7fbf..77e91bc39 100644 --- a/backend/app/DomainObjects/OrderDomainObject.php +++ b/backend/app/DomainObjects/OrderDomainObject.php @@ -27,6 +27,8 @@ class OrderDomainObject extends Generated\OrderDomainObjectAbstract implements I public ?StripePaymentDomainObject $stripePayment = null; + public ?RazorpayOrderDomainObject $razorpayOrder = null; + /** @var Collection|null */ public ?Collection $questionAndAnswerViews = null; @@ -144,7 +146,7 @@ public function getAttendees(): ?Collection public function isPaymentRequired(): bool { - return (int)ceil($this->getTotalGross()) > 0; + return (int) ceil($this->getTotalGross()) > 0; } public function isOrderAwaitingOfflinePayment(): bool @@ -183,6 +185,12 @@ public function setStripePayment(?StripePaymentDomainObject $stripePayment): Ord return $this; } + public function setRazorpayOrder(?RazorpayOrderDomainObject $razorpayOrder): OrderDomainObject + { + $this->razorpayOrder = $razorpayOrder; + return $this; + } + public function isPartiallyRefunded(): bool { return $this->getTotalRefunded() > 0 && $this->getTotalRefunded() < $this->getTotalGross(); @@ -223,6 +231,11 @@ public function getStripePayment(): ?StripePaymentDomainObject return $this->stripePayment; } + public function getRazorpayOrder(): ?RazorpayOrderDomainObject + { + return $this->razorpayOrder; + } + public function isFreeOrder(): bool { return $this->getTotalGross() === 0.00; @@ -283,12 +296,27 @@ public function getSessionIdentifier(): ?string public function isRefundable(): bool { + $allowedProviders = [ + PaymentProviders::STRIPE->name, + PaymentProviders::RAZORPAY->name, + ]; + return !$this->isFreeOrder() && $this->getStatus() !== OrderPaymentStatus::AWAITING_OFFLINE_PAYMENT->name - && $this->getPaymentProvider() === PaymentProviders::STRIPE->name + && in_array($this->getPaymentProvider(), $allowedProviders, true) && $this->getRefundStatus() !== OrderRefundStatus::REFUNDED->name; } + public function isRazorpayOrder(): bool + { + return $this->getPaymentProvider() === PaymentProviders::RAZORPAY->name; + } + + public function hasRazorpayOrder(): bool + { + return $this->razorpayOrder !== null; + } + public function getAddressDTO(): ?AddressDTO { if ($this->getAddress() === null) { diff --git a/backend/app/DomainObjects/RazorpayOrderDomainObject.php b/backend/app/DomainObjects/RazorpayOrderDomainObject.php index 5d2df336d..ce6a5d028 100644 --- a/backend/app/DomainObjects/RazorpayOrderDomainObject.php +++ b/backend/app/DomainObjects/RazorpayOrderDomainObject.php @@ -2,6 +2,24 @@ namespace HiEvents\DomainObjects; -class RazorpayOrderDomainObject extends Generated\RazorpayOrderDomainObjectAbstract +use HiEvents\DomainObjects\Generated\RazorpayOrderDomainObjectAbstract; + +class RazorpayOrderDomainObject extends RazorpayOrderDomainObjectAbstract { + // Additional methods or overrides can be added here + + public function isPaid(): bool + { + return in_array($this->payment_status, ['captured', 'paid']); + } + + public function isFailed(): bool + { + return $this->payment_status === 'failed'; + } + + public function isPending(): bool + { + return $this->payment_status === 'created'; + } } diff --git a/backend/app/DomainObjects/RefundAttemptDomainObject.php b/backend/app/DomainObjects/RefundAttemptDomainObject.php new file mode 100644 index 000000000..f3fb0f72a --- /dev/null +++ b/backend/app/DomainObjects/RefundAttemptDomainObject.php @@ -0,0 +1,7 @@ +getContent(); + $signature = $request->header('X-Razorpay-Signature'); + + dispatch(static function (RazorpayWebhookHandler $handler) use ($payload, $signature) { + $handler->handle($payload, $signature); + })->catch(function (\Throwable $exception) use ($payload) { + logger()->error(__('Failed to handle incoming Razorpay webhook'), [ + 'exception' => $exception, + 'payload' => $payload, + ]); + }); + + return $this->noContentResponse(); + } +} \ No newline at end of file diff --git a/backend/app/Http/Actions/Orders/Payment/Razorpay/CreateRazorpayOrderActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Razorpay/CreateRazorpayOrderActionPublic.php new file mode 100644 index 000000000..66d6c747a --- /dev/null +++ b/backend/app/Http/Actions/Orders/Payment/Razorpay/CreateRazorpayOrderActionPublic.php @@ -0,0 +1,33 @@ +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..268572053 --- /dev/null +++ b/backend/app/Http/Actions/Orders/Payment/Razorpay/VerifyRazorpayPaymentActionPublic.php @@ -0,0 +1,43 @@ +validated(); + $verifyRazorpayPaymentDTO = new VerifyRazorpayPaymentDTO( + razorpay_payment_id: $validated['razorpay_payment_id'], + razorpay_order_id: $validated['razorpay_order_id'], + razorpay_signature: $validated['razorpay_signature'], + ); + + $order = $this->verifyRazorpayPaymentHandler->handle( + $orderShortId, + $verifyRazorpayPaymentDTO + ); + } 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/Http/Actions/Orders/Payment/RefundOrderAction.php b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php index 205409e2e..a0f22e4c2 100644 --- a/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php +++ b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php @@ -8,7 +8,7 @@ use HiEvents\Http\Request\Order\RefundOrderRequest; use HiEvents\Resources\Order\OrderResource; use HiEvents\Services\Application\Handlers\Order\DTO\RefundOrderDTO; -use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\RefundOrderHandler; +use HiEvents\Services\Application\Handlers\Order\Payment\RefundOrderHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Stripe\Exception\ApiErrorException; diff --git a/backend/app/Http/Request/Order/VerifyRazorpayPaymentRequest.php b/backend/app/Http/Request/Order/VerifyRazorpayPaymentRequest.php new file mode 100644 index 000000000..2c59d27ba --- /dev/null +++ b/backend/app/Http/Request/Order/VerifyRazorpayPaymentRequest.php @@ -0,0 +1,50 @@ + [ + 'required', + 'string', + 'regex:/^pay_[a-zA-Z0-9]+$/', + ], + 'razorpay_order_id' => [ + 'required', + 'string', + 'regex:/^order_[a-zA-Z0-9]+$/', + ], + 'razorpay_signature' => [ + 'required', + 'string', + 'regex:/^[a-f0-9]{64}$/', + ], + ]; + } + + public function messages(): array + { + return [ + 'razorpay_payment_id.required' => __('The Razorpay payment ID is required.'), + 'razorpay_payment_id.regex' => __('The Razorpay payment ID must start with "pay_" and contain only alphanumeric characters.'), + 'razorpay_order_id.required' => __('The Razorpay order ID is required.'), + 'razorpay_order_id.regex' => __('The Razorpay order ID must start with "order_" and contain only alphanumeric characters.'), + 'razorpay_signature.required' => __('The Razorpay signature is required.'), + 'razorpay_signature.regex' => __('The Razorpay signature format is invalid (must be a 64-character hex string).'), + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'razorpay_payment_id' => trim($this->razorpay_payment_id), + 'razorpay_order_id' => trim($this->razorpay_order_id), + 'razorpay_signature' => trim($this->razorpay_signature), + ]); + } +} \ 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..dc50f141d --- /dev/null +++ b/backend/app/Models/RazorpayOrder.php @@ -0,0 +1,37 @@ + 'integer', + 'fee' => 'integer', + 'tax' => '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/Models/RefundAttempt.php b/backend/app/Models/RefundAttempt.php new file mode 100644 index 000000000..edd3362bb --- /dev/null +++ b/backend/app/Models/RefundAttempt.php @@ -0,0 +1,24 @@ + 'array', + 'response_data' => 'array', + 'attempts' => 'integer', + ]; +} \ No newline at end of file diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 55f77ef5b..dc3690e80 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -42,6 +42,8 @@ use HiEvents\Repository\Eloquent\QuestionAndAnswerViewRepository; use HiEvents\Repository\Eloquent\QuestionAnswerRepository; use HiEvents\Repository\Eloquent\QuestionRepository; +use HiEvents\Repository\Eloquent\RazorpayOrdersRepository; +use HiEvents\Repository\Eloquent\RefundAttemptRepository; use HiEvents\Repository\Eloquent\StripeCustomerRepository; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\StripePayoutsRepository; @@ -89,6 +91,8 @@ use HiEvents\Repository\Interfaces\QuestionAndAnswerViewRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; +use HiEvents\Repository\Interfaces\RefundAttemptRepositoryInterface; use HiEvents\Repository\Interfaces\StripeCustomerRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Repository\Interfaces\StripePayoutsRepositoryInterface; @@ -118,6 +122,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, @@ -153,6 +158,7 @@ class RepositoryServiceProvider extends ServiceProvider TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, + RefundAttemptRepositoryInterface::class => RefundAttemptRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/OrderRefundRepository.php b/backend/app/Repository/Eloquent/OrderRefundRepository.php index cfd4ef7ce..e23788f0b 100644 --- a/backend/app/Repository/Eloquent/OrderRefundRepository.php +++ b/backend/app/Repository/Eloquent/OrderRefundRepository.php @@ -20,4 +20,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/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/Eloquent/RefundAttemptRepository.php b/backend/app/Repository/Eloquent/RefundAttemptRepository.php new file mode 100644 index 000000000..8c50cc7b4 --- /dev/null +++ b/backend/app/Repository/Eloquent/RefundAttemptRepository.php @@ -0,0 +1,58 @@ +findFirstWhere(['idempotency_key' => $key]); + } + + public function createAttempt(string $key, int $paymentId, string $paymentType, array $requestData): RefundAttemptDomainObject + { + return $this->create([ + 'idempotency_key' => $key, + 'payment_id' => $paymentId, + 'payment_type' => $paymentType, + 'request_data' => json_encode($requestData), // Encode to JSON string + 'status' => 'pending', + 'attempts' => 0, + ]); + } + + public function markSucceeded(int $id, array $responseData): bool + { + return (bool) $this->updateFromArray($id, [ + 'status' => 'succeeded', + 'response_data' => json_encode($responseData), // Encode to JSON string + ]); + } + + public function markFailed(int $id, array $responseData = []): bool + { + return (bool) $this->updateFromArray($id, [ + 'status' => 'failed', + 'response_data' => json_encode($responseData), // Encode to JSON string + ]); + } + + public function incrementAttempts(int $id): bool + { + return (bool) $this->model->where('id', $id)->increment('attempts'); + } +} \ No newline at end of file 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/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: $this->config->get('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 $razorpayOrder; + } +} \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php new file mode 100644 index 000000000..982ecf6cc --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php @@ -0,0 +1,151 @@ +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); + try { + $envelope = RazorpayWebhookEnvelope::fromArray($data); + } catch (\InvalidArgumentException $e) { + $this->logger->debug('Unsupported or unknown webhook event', [ + 'event' => $data['event'] ?? 'unknown', + 'error' => $e->getMessage() + ]); + return; + } + $event = $envelope->event; + + // 3. Validate event type + if (!in_array($event, self::$validEvents, true)) { + $this->logger->debug('Unsupported webhook event', ['event' => $event]); + return; + } + + // 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, + ]); + return; + } + + $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 $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 $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; + } + } + + private function hasEventBeenHandled(string $eventId): bool + { + return $this->cache->has('razorpay_webhook_' . $eventId); + } + + private function markEventAsHandled(string $eventId): void + { + $this->logger->info('Marking Razorpay webhook event as handled', [ + 'event_id' => $eventId, + ]); + $this->cache->put('razorpay_webhook_' . $eventId, true, now()->addMinutes(60)); + } +} \ 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..49d8d982a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/VerifyRazorpayPaymentHandler.php @@ -0,0 +1,219 @@ +hasPaymentBeenHandled($verifyRazorpayPaymentData)) { + $this->logger->info('Razorpay payment already handled', [ + 'razorpay_payment_id' => $verifyRazorpayPaymentData->razorpay_payment_id, + 'order_short_id' => $orderShortId, + ]); + + // Still return the order for user feedback + return $this->orderRepository + ->loadRelation(new Relationship(OrderItemDomainObject::class)) + ->findByShortId($orderShortId); + } + + return $this->databaseManager->transaction(function () use ($orderShortId, $verifyRazorpayPaymentData) { + // Load order with necessary relations + $order = $this->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($verifyRazorpayPaymentData); + + 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' => $verifyRazorpayPaymentData->razorpay_payment_id, + 'razorpay_signature' => $verifyRazorpayPaymentData->razorpay_signature, + 'status' => 'captured', + ]); + + // Fetch complete payment details from Razorpay API + $paymentDetails = $this->razorpayPaymentService->fetchPaymentDetails( + $verifyRazorpayPaymentData->razorpay_payment_id + ); + + // Update order status to completed - THIS RETURNS THE UPDATED ORDER WITH ITEMS LOADED + $updatedOrder = $this->updateOrderStatuses($order); + + // Update attendee statuses + $this->updateAttendeeStatuses($updatedOrder); + + // Update product quantities - USE THE UPDATED ORDER WITH ITEMS LOADED + $this->quantityUpdateService->updateQuantitiesFromOrder($updatedOrder); + + // Update affiliate sales + $this->updateAffiliateSales($updatedOrder); + + // Store application fee + $this->storeApplicationFeePayment($updatedOrder, $paymentDetails); + + // Dispatch events + $this->dispatchEvents($updatedOrder); + + // Mark payment as handled + $this->markPaymentAsHandled($verifyRazorpayPaymentData, $updatedOrder); + + return $updatedOrder; + }); + } + + private function updateOrderStatuses(OrderDomainObject $order): OrderDomainObject + { + // IMPORTANT: Load OrderItemDomainObject relation when updating, just like Stripe handler does + return $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->updateFromArray($order->getId(), [ + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->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 + { + if ($order->getAffiliateId()) { + $this->affiliateRepository->incrementSales( + affiliateId: $order->getAffiliateId(), + amount: $order->getTotalGross() + ); + } + } + + private function storeApplicationFeePayment(OrderDomainObject $order, array $paymentDetails): void + { + $feeAmount = $paymentDetails['fee'] ?? 0; // Fee in paise + + $this->orderApplicationFeeService->createOrderApplicationFee( + orderId: $order->getId(), + applicationFeeAmountMinorUnit: $feeAmount, + orderApplicationFeeStatus: OrderApplicationFeeStatus::PAID, + paymentMethod: PaymentProviders::RAZORPAY, + currency: $order->getCurrency(), + ); + } + + private function dispatchEvents(OrderDomainObject $order): void + { + // Dispatch Laravel event + OrderStatusChangedEvent::dispatch($order); + + // Dispatch domain event + $this->domainEventDispatcherService->dispatch( + new OrderEvent( + type: DomainEventType::ORDER_CREATED, + orderId: $order->getId() + ) + ); + } + + private function hasPaymentBeenHandled(VerifyRazorpayPaymentDTO $verifyRazorpayPaymentData): bool + { + $paymentId = $verifyRazorpayPaymentData->razorpay_payment_id ?? null; + if (!$paymentId) { + return false; + } + + return $this->cache->has('razorpay_payment_handled_' . $paymentId); + } + + private function markPaymentAsHandled(VerifyRazorpayPaymentDTO $verifyRazorpayPaymentData, OrderDomainObject $order): void + { + $this->logger->info('Razorpay payment verification handled', [ + 'razorpay_payment_id' => $verifyRazorpayPaymentData->razorpay_payment_id, + 'order_id' => $order->getId(), + 'amount' => $order->getTotalGross(), + 'currency' => $order->getCurrency(), + ]); + + $this->cache->put( + 'razorpay_payment_handled_' . $verifyRazorpayPaymentData->razorpay_payment_id, + true, + now()->addHours(24) + ); + } +} \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/RefundOrderHandler.php similarity index 62% rename from backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php rename to backend/app/Services/Application/Handlers/Order/Payment/RefundOrderHandler.php index 2882194d4..ad2419d78 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/RefundOrderHandler.php @@ -1,6 +1,6 @@ databaseManager->transaction(fn() => $this->refundOrder($refundOrderDTO)); + return $this->dbConnection->transaction(fn() => $this->refundOrder($refundOrderDTO)); } private function fetchOrder(int $eventId, int $orderId): OrderDomainObject { $order = $this->orderRepository ->loadRelation(new Relationship(StripePaymentDomainObject::class, name: 'stripe_payment')) + ->loadRelation(new Relationship(RazorpayOrderDomainObject::class, name: 'razorpay_order')) ->findFirstWhere(['event_id' => $eventId, 'id' => $orderId]); if (!$order) { @@ -74,8 +79,9 @@ private function fetchOrder(int $eventId, int $orderId): OrderDomainObject */ private function validateRefundability(OrderDomainObject $order): void { - if (!$order->getStripePayment()) { - throw new RefundNotPossibleException(__('There is no Stripe data associated with this order.')); + $payment = $order->getStripePayment() ?? $order->getRazorpayOrder(); + if (!$payment) { + throw new RefundNotPossibleException(__('There is no payment data associated with this order.')); } if ($order->getRefundStatus() === OrderRefundStatus::REFUND_PENDING->name) { @@ -110,6 +116,16 @@ private function markOrderRefundPending(OrderDomainObject $order): OrderDomainOb ); } + private function generateRazorpayIdempotencyKey(OrderDomainObject $order, MoneyValue $amount, array $options): ?string + { + if (!$this->config->get('refunds.razorpay.idempotency_enabled', true)) { + return null; + } + + $data = $order->getId() . '_' . $amount->toMinorUnit() . '_' . json_encode($options); + return 'refund_' . hash('sha256', $data); + } + /** * @throws ApiErrorException * @throws UnknownCurrencyException @@ -135,18 +151,32 @@ private function refundOrder(RefundOrderDTO $refundOrderDTO): OrderDomainObject $this->orderCancelService->cancelOrder($order); } - // Determine the correct Stripe platform for this refund - // Use the platform that was used for the original payment - $paymentPlatform = $order->getStripePayment()->getStripePlatformEnum(); + if ($order->getStripePayment()) { + // Determine the correct Stripe platform for this refund + // Use the platform that was used for the original payment + $paymentPlatform = $order->getStripePayment()->getStripePlatformEnum(); - // Create Stripe client for the original payment's platform - $stripeClient = $this->stripeClientFactory->createForPlatform($paymentPlatform); + // Create Stripe client for the original payment's platform + $stripeClient = $this->stripeClientFactory->createForPlatform($paymentPlatform); - $this->refundService->refundPayment( - amount: $amount, - payment: $order->getStripePayment(), - stripeClient: $stripeClient - ); + $this->stripeRefundService->refundPayment( + amount: $amount, + payment: $order->getStripePayment(), + stripeClient: $stripeClient + ); + } elseif ($order->getRazorpayOrder()) { + $options = $refundOrderDTO->provider_options ?? []; + $idempotencyKey = $this->generateRazorpayIdempotencyKey($order, $amount, $options); + + $this->idempotentRefundService->refundWithIdempotency( + $order->getRazorpayOrder(), + $amount, + $options, + $idempotencyKey + ); + } else { + throw new RefundNotPossibleException(__('No payment provider found for this order.')); + } if ($refundOrderDTO->notify_buyer) { $this->notifyBuyer($order, $event, $amount); diff --git a/backend/app/Services/Domain/Payment/IdempotentRefundService.php b/backend/app/Services/Domain/Payment/IdempotentRefundService.php new file mode 100644 index 000000000..7e7d690c6 --- /dev/null +++ b/backend/app/Services/Domain/Payment/IdempotentRefundService.php @@ -0,0 +1,100 @@ +refundService->refundPayment( + $payment, + $amount->toMinorUnit(), + null, + $options + ); + } + + return $this->dbManager->transaction(function () use ($payment, $amount, $options, $idempotencyKey) { + // Lock the attempt row if exists + $attempt = $this->attemptRepository + ->findByIdempotencyKey($idempotencyKey); + + if ($attempt) { + return $this->handleExistingAttempt($attempt); + } + + // Create new attempt + $paymentId = $payment->getId(); + $paymentType = $this->getPaymentType($payment); + $attempt = $this->attemptRepository->createAttempt( + $idempotencyKey, + $paymentId, + $paymentType, + ['amount' => $amount->toMinorUnit(), 'options' => $options] + ); + + try { + $result = $this->refundService->refundPayment( + $payment, + $amount->toMinorUnit(), + $idempotencyKey, + $options + ); + + $this->attemptRepository->markSucceeded($attempt->getId(), (array) $result); + return $result; + } catch (Throwable $e) { + $this->attemptRepository->markFailed($attempt->getId(), ['error' => $e->getMessage()]); + throw $e; + } + }); + } + + private function handleExistingAttempt(RefundAttemptDomainObject $attempt): object + { + if ($attempt->getStatus() === 'succeeded') { + return (object) $attempt->getResponseData(); + } + + if ($attempt->getStatus() === 'failed') { + $this->attemptRepository->incrementAttempts($attempt->getId()); + throw new RefundNotPossibleException('Previous refund attempt failed. Please retry.'); + } + + throw new RefundNotPossibleException('A refund for this order is already being processed.'); + } + + private function getPaymentType(object $payment): string + { + return match (true) { + $payment instanceof \HiEvents\DomainObjects\StripePaymentDomainObject => 'stripe', + $payment instanceof \HiEvents\DomainObjects\RazorpayOrderDomainObject => 'razorpay', + default => throw new \InvalidArgumentException('Unknown payment type'), + }; + } +} \ 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 @@ + 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/DTOs/VerifyRazorpayPaymentDTO.php b/backend/app/Services/Domain/Payment/Razorpay/DTOs/VerifyRazorpayPaymentDTO.php new file mode 100644 index 000000000..89d90fb95 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/DTOs/VerifyRazorpayPaymentDTO.php @@ -0,0 +1,16 @@ +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->dbConnection->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); + + // 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..d29b9bed3 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandler.php @@ -0,0 +1,69 @@ +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->dbConnection->transaction(function () use ($paymentEntity) { + // Try to find by payment ID first, then by order ID + $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentEntity->id) + ?? $this->razorpayOrdersRepository->findByRazorpayOrderId($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 new file mode 100644 index 000000000..990c34661 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandler.php @@ -0,0 +1,193 @@ +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' => $paymentEntity->id, + ]); + return; + } + + $this->dbConnection->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' => $paymentEntity->id, + ]); + return; + } + + // 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, + ]); + return; + } + + // 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' => $paymentEntity->id, + 'order_id' => $orderId, + ]); + return; + } + + // Update the razorpay_orders record with webhook data (all amounts in paise) + $this->razorpayOrdersRepository->updateByOrderId($orderId, [ + 'razorpay_payment_id' => $paymentEntity->id, + 'status' => $paymentEntity->status, + 'method' => $paymentEntity->method, + 'amount' => $paymentEntity->amount, + 'currency' => $paymentEntity->currency, + 'fee' => $paymentEntity->fee, + 'tax' => $paymentEntity->tax, + ]); + + // If the order is not already marked as paid, update its status and related entities + $orderArray = $order->toArray(); + $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, + orderId: $updatedOrder->getId() + ) + ); + } + + // 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); + }); + } + + 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() + ); + } + } + + private function isPaymentAlreadyHandled(string $paymentId): bool + { + return $this->cache->has('razorpay_webhook_payment_' . $paymentId); + } + + private function markPaymentAsHandled(string $paymentId, OrderDomainObject $order): void + { + $this->logger->info('Razorpay payment captured via webhook', [ + 'razorpay_payment_id' => $paymentId, + 'order_id' => $order->getId(), + 'amount' => $order->getTotalGross(), + 'currency' => $order->getCurrency(), + ]); + + $this->cache->put('razorpay_webhook_payment_' . $paymentId, true, now()->addHours(24)); + } +} \ No newline at end of file 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..3672062fe --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandler.php @@ -0,0 +1,82 @@ +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->dbConnection->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->findByRazorpayOrderId($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/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandler.php new file mode 100644 index 000000000..51cbba0e0 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandler.php @@ -0,0 +1,128 @@ +refund; + $paymentId = $refundEntity->payment_id; + + // Idempotency key based on refund ID + $idempotencyKey = 'razorpay_refund_' . $refundEntity->id; + + if ($this->cache->has($idempotencyKey)) { + $this->logger->info('Razorpay refund event already handled', [ + 'refund_id' => $refundEntity->id, + 'payment_id' => $paymentId, + ]); + return; + } + + $this->dbConnection->transaction(function () use ($refundEntity, $paymentId) { + // 1. Find the Razorpay order record by payment ID + $razorpayOrder = $this->razorpayOrdersRepository->findByPaymentId($paymentId); + + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for refund webhook', [ + 'payment_id' => $paymentId, + 'refund_id' => $refundEntity->id, + ]); + return; + } + + $localOrderId = $razorpayOrder->getOrderId(); + + // 2. Load the full order with items + $order = $this->orderRepository + ->loadRelation(new Relationship(OrderItemDomainObject::class)) + ->findById($localOrderId); + + if (!$order) { + $this->logger->warning('Local order not found for refund webhook', [ + 'local_order_id' => $localOrderId, + 'refund_id' => $refundEntity->id, + ]); + return; + } + + // 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(), + ], + ]); + + // 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, + ]); + }); + + // Mark as handled after successful transaction + $this->cache->put($idempotencyKey, true, now()->addHours(24)); + } + + private function updateOrderPaymentStatus(OrderDomainObject $order): void + { + // Get total refunded amount for this order using repository method + $totalRefunded = $this->refundRepository->getTotalRefundedForOrder($order->getId()); + $orderTotal = $order->getTotalGross(); // Assume returns in rupees + + if ($totalRefunded <= 0) { + return; // No change if no refunds + } + + if ($totalRefunded >= $orderTotal) { + $newStatus = OrderPaymentStatus::REFUNDED->name; + } else { + $newStatus = OrderPaymentStatus::PARTIALLY_REFUNDED->name; + } + + $this->orderRepository->updateFromArray($order->getId(), [ + 'refund_status' => $newStatus, + 'total_refunded' => $totalRefunded + ]); + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/RazorpayOrderCreationService.php b/backend/app/Services/Domain/Payment/Razorpay/RazorpayOrderCreationService.php new file mode 100644 index 000000000..2da78b429 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpayOrderCreationService.php @@ -0,0 +1,119 @@ +dbConnection->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 = (int) round($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(), + ], + ]; + + // TODO: Fix for saas mode + // if ($applicationFee && $this->config->get('services.razorpay.application_fee_enabled')) { + // $feeMinorUnit = $applicationFee->grossApplicationFee->toMinorUnit(); + + // $orderData['transfers'] = [ + // [ + // 'account' => $this->config->get('services.razorpay.platform_account_id'), + // 'amount' => $feeMinorUnit, + // 'currency' => $orderDTO->currencyCode, + // 'notes' => [ + // 'order_id' => $orderDTO->order->getId(), + // 'type' => 'application_fee' + // ], + // ] + // ]; + // } + + $razorpayOrder = $razorpayClient->createOrder($orderData); + + $this->logger->debug('Razorpay order created', [ + 'razorpayOrderId' => $razorpayOrder->id, + 'orderDTO' => $orderDTO->toArray(['account']), + ]); + + $this->dbConnection->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) { + $this->logger->error("Razorpay order creation failed: {$exception->getMessage()}", [ + 'exception' => $exception, + 'orderDTO' => $orderDTO->toArray(['account']), + ]); + + $this->dbConnection->rollBack(); + + throw new CreateOrderFailedException( + __('There was an error communicating with the payment provider. Please try again later.') + ); + } catch (Throwable $exception) { + $this->dbConnection->rollBack(); + + throw $exception; + } + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentRefundService.php b/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentRefundService.php new file mode 100644 index 000000000..4a7c4bf4a --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentRefundService.php @@ -0,0 +1,54 @@ +getRazorpayPaymentId(); + if (!$paymentId) { + throw new RefundNotPossibleException(__('No Razorpay payment ID found for this order.')); + } + + try { + $client = $this->clientFactory->create(); + $params = array_merge( + ['payment_id' => $paymentId, 'amount' => $amountInPaise], + $options + ); + return $client->refundPayment($params, $idempotencyKey); + } catch (BadRequestError $e) { + // Handle insufficient balance error + if (str_contains($e->getMessage(), 'enough balance')) { + $this->logger->error('Razorpay refund failed: insufficient balance', [ + 'payment_id' => $paymentId, + 'amount' => $amountInPaise, + 'error' => $e->getMessage() + ]); + + throw new RefundNotPossibleException( + __('Refund failed due to insufficient account balance. Please add funds to your Razorpay account or try again later.') + ); + } + + throw $e; + } + } +} \ 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..4a28eb702 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationService.php @@ -0,0 +1,76 @@ +razorpay_order_id . '|' . $verifyRazorpayPaymentData->razorpay_payment_id, + $this->config->get('services.razorpay.key_secret') + ); + + if ($expectedSignature !== $verifyRazorpayPaymentData->razorpay_signature) { + $this->logger->error('Razorpay signature verification failed', [ + 'expected' => $expectedSignature, + 'received' => $verifyRazorpayPaymentData->razorpay_signature, + 'order_id' => $verifyRazorpayPaymentData->razorpay_order_id, + 'payment_id' => $verifyRazorpayPaymentData->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->fetchPayment($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/RazorpayApiClient.php b/backend/app/Services/Infrastructure/Razorpay/RazorpayApiClient.php new file mode 100644 index 000000000..8e2fcfde2 --- /dev/null +++ b/backend/app/Services/Infrastructure/Razorpay/RazorpayApiClient.php @@ -0,0 +1,33 @@ +api = $api ?? new Api($keyId, $keySecret); + } + + public function createOrder(array $data): object + { + return $this->api->order->create($data); + } + + public function fetchPayment(string $paymentId): object + { + return $this->api->payment->fetch($paymentId); + } + + public function refundPayment(array $params, string|null $idempotencyKey = null): object + { + $paymentId = $params['payment_id']; + unset($params['payment_id']); + $payment = $this->api->payment->fetch($paymentId); + return $payment->refund($params, $idempotencyKey); + } +} \ 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..4db0a7c02 --- /dev/null +++ b/backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php @@ -0,0 +1,43 @@ +getCredentials(); + + if (empty($keyId) || empty($keySecret)) { + throw new \RuntimeException( + 'Razorpay credentials not configured. Please set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET in your environment.' + ); + } + + return new RazorpayApiClient($keyId, $keySecret); + } + + private function getCredentials(): array + { + $keyId = $this->config->get('services.razorpay.key_id'); + $keySecret = $this->config->get('services.razorpay.key_secret'); + + if (empty($keyId) || empty($keySecret)) { + throw new \RuntimeException( + 'Razorpay credentials not configured. Please set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET in your environment.' + ); + } + + return [$keyId, $keySecret]; + } +} \ No newline at end of file diff --git a/backend/app/Services/Infrastructure/Razorpay/RazorpayClientInterface.php b/backend/app/Services/Infrastructure/Razorpay/RazorpayClientInterface.php new file mode 100644 index 000000000..bfea71998 --- /dev/null +++ b/backend/app/Services/Infrastructure/Razorpay/RazorpayClientInterface.php @@ -0,0 +1,12 @@ +=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/refunds.php b/backend/config/refunds.php new file mode 100644 index 000000000..99c83a052 --- /dev/null +++ b/backend/config/refunds.php @@ -0,0 +1,7 @@ + [ + 'idempotency_enabled' => env('RAZORPAY_REFUND_IDEMPOTENCY_ENABLED', true), + ] +]; \ No newline at end of file diff --git a/backend/config/services.php b/backend/config/services.php index 44f123a1e..aa66a69d3 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -52,4 +52,12 @@ 'open_exchange_rates' => [ 'app_id' => env('OPEN_EXCHANGE_RATES_APP_ID'), ], + + 'razorpay' => [ + 'key_id' => env('RAZORPAY_KEY_ID'), + 'key_secret' => env('RAZORPAY_KEY_SECRET'), + 'webhook_secret' => env('RAZORPAY_WEBHOOK_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..bf4fc008e --- /dev/null +++ b/backend/database/migrations/2026_01_14_074419_create_razorpay_orders_table.php @@ -0,0 +1,47 @@ +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->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('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'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('razorpay_orders'); + } +}; diff --git a/backend/database/migrations/2026_03_06_084011_create_refund_attempts_table.php b/backend/database/migrations/2026_03_06_084011_create_refund_attempts_table.php new file mode 100644 index 000000000..55342648f --- /dev/null +++ b/backend/database/migrations/2026_03_06_084011_create_refund_attempts_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('idempotency_key', 100)->unique(); + $table->morphs('payment'); + $table->string('status'); + $table->json('request_data')->nullable(); + $table->json('response_data')->nullable(); + $table->integer('attempts')->default(0); + $table->timestamps(); + + $table->index(['payment_id', 'payment_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refund_attempts'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 09ca424e4..4f2572fa9 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -46,6 +46,7 @@ use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListPublicAction; use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction; use HiEvents\Http\Actions\Common\GetColorThemesAction; +use HiEvents\Http\Actions\Common\Webhooks\RazorpayIncomingWebhookAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; use HiEvents\Http\Actions\Events\CreateEventAction; use HiEvents\Http\Actions\Events\DuplicateEventAction; @@ -89,6 +90,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; @@ -514,6 +517,11 @@ 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); + $router->post('/webhooks/razorpay', RazorpayIncomingWebhookAction::class); // Questions $router->get('/events/{event_id}/questions', GetQuestionsPublicAction::class); diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandlerTest.php new file mode 100644 index 000000000..a10d872cf --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandlerTest.php @@ -0,0 +1,214 @@ +orderRepoMock = $this->createMock(OrderRepositoryInterface::class); + $this->razorpayOrderServiceMock = $this->createMock(RazorpayOrderCreationService::class); + $this->sessionServiceMock = $this->createMock(CheckoutSessionManagementService::class); + $this->razorpayOrdersRepoMock = $this->createMock(RazorpayOrdersRepositoryInterface::class); + $this->accountRepoMock = $this->createMock(AccountRepositoryInterface::class); + $this->configMock = $this->createMock(Repository::class); + + // Handle fluent interface chaining consistently + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->accountRepoMock->method('loadRelation')->willReturnSelf(); + + $this->handler = new CreateRazorpayOrderHandler( + $this->orderRepoMock, + $this->razorpayOrderServiceMock, + $this->sessionServiceMock, + $this->razorpayOrdersRepoMock, + $this->accountRepoMock, + $this->configMock + ); + } + + // ------------------------------------------------------------------------- + // UNHAPPY PATHS + // ------------------------------------------------------------------------- + + public function testItThrowsExceptionIfOrderNotFound(): void + { + $this->orderRepoMock->expects($this->once()) + ->method('findByShortId') + ->with('SHORT_123') + ->willReturn(null); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('Sorry, we could not verify your session. Please create a new order.'); + + $this->handler->handle('SHORT_123'); + } + + public function testItThrowsExceptionIfSessionIsInvalid(): void + { + $orderMock = $this->createMockedOrder(); + + $this->orderRepoMock->method('findByShortId')->willReturn($orderMock); + + $this->sessionServiceMock->expects($this->once()) + ->method('verifySession') + ->with('session_abc') + ->willReturn(false); + + $this->expectException(UnauthorizedException::class); + + $this->handler->handle('SHORT_123'); + } + + public function testItThrowsExceptionIfOrderIsNotReservedOrIsExpired(): void + { + // Use helper to quickly generate an expired order + $orderMock = $this->createMockedOrder(OrderStatus::COMPLETED->name, true); + + $this->orderRepoMock->method('findByShortId')->willReturn($orderMock); + $this->sessionServiceMock->method('verifySession')->willReturn(true); + + $this->expectException(ResourceConflictException::class); + + $this->handler->handle('SHORT_123'); + } + + // ------------------------------------------------------------------------- + // HAPPY PATHS + // ------------------------------------------------------------------------- + + public function testItReturnsEarlyIfRazorpayOrderAlreadyExists(): void + { + // 1. Arrange Existing Razorpay Data + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getRazorpayOrderId')->willReturn('rp_existing_123'); + $razorpayOrderMock->method('getAmount')->willReturn(50000); + $razorpayOrderMock->method('getCurrency')->willReturn('INR'); + + // Use helper to build order WITH the existing Razorpay object attached + $orderMock = $this->createMockedOrder(OrderStatus::RESERVED->name, false, $razorpayOrderMock); + + $this->orderRepoMock->method('findByShortId')->willReturn($orderMock); + $this->sessionServiceMock->method('verifySession')->willReturn(true); + $this->accountRepoMock->method('findByEventId')->willReturn($this->createMock(AccountDomainObject::class)); + + $this->configMock->method('get') + ->with('services.razorpay.key_id') + ->willReturn('test_key_123'); + + // Verify we strictly avoid calling the external service or DB + $this->razorpayOrderServiceMock->expects($this->never())->method('createOrder'); + $this->razorpayOrdersRepoMock->expects($this->never())->method('create'); + + // 2. Act + $response = $this->handler->handle('SHORT_123'); + + // 3. Assert + $this->assertInstanceOf(CreateRazorpayOrderResponseDTO::class, $response); + $this->assertEquals('rp_existing_123', $response->id); + } + + public function testItCreatesNewRazorpayOrderSuccessfully(): void + { + // 1. Arrange Order & Account + $orderMock = $this->createMockedOrder(); // Defaults to valid/reserved + $accountMock = $this->createMock(AccountDomainObject::class); + + $this->orderRepoMock->method('findByShortId')->willReturn($orderMock); + $this->sessionServiceMock->method('verifySession')->willReturn(true); + $this->accountRepoMock->method('findByEventId')->willReturn($accountMock); + + $this->configMock->method('get') + ->with('services.razorpay.key_id') + ->willReturn('test_key_123'); + + $expectedServiceResponse = new CreateRazorpayOrderResponseDTO( + id: 'rp_new_123', + keyId: 'test_key_123', + amount: 50000, + currency: 'INR' + ); + + // 2. Assert Service is Called with correctly mapped DTO + $this->razorpayOrderServiceMock->expects($this->once()) + ->method('createOrder') + ->with($this->callback(function (CreateRazorpayOrderRequestDTO $dto) use ($accountMock, $orderMock) { + return $dto->currencyCode === 'INR' + && $dto->account === $accountMock + && $dto->order === $orderMock; + })) + ->willReturn($expectedServiceResponse); + + // 3. Assert Repository Creates Record + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('create') + ->with([ + 'order_id' => 10, + 'razorpay_order_id' => 'rp_new_123', + 'amount' => 50000, + 'currency' => 'INR', + 'receipt' => 'SHORT_123', + ]); + + // 4. Act + $response = $this->handler->handle('SHORT_123'); + + // 5. Assert Response + $this->assertInstanceOf(CreateRazorpayOrderResponseDTO::class, $response); + $this->assertEquals('rp_new_123', $response->id); + } + + // ------------------------------------------------------------------------- + // HELPERS + // ------------------------------------------------------------------------- + + /** + * Helper method to keep test bodies clean and consistent. + */ + private function createMockedOrder( + string $status = 'RESERVED', // Using string default to avoid enum lookup in signature + bool $isExpired = false, + ?RazorpayOrderDomainObject $razorpayOrder = null + ): OrderDomainObject&MockObject { + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('getSessionId')->willReturn('session_abc'); + $orderMock->method('getStatus')->willReturn($status); + $orderMock->method('isReservedOrderExpired')->willReturn($isExpired); + $orderMock->method('getEventId')->willReturn(99); + $orderMock->method('getRazorpayOrder')->willReturn($razorpayOrder); + $orderMock->method('getTotalGross')->willReturn(500.00); + $orderMock->method('getCurrency')->willReturn('INR'); + $orderMock->method('getId')->willReturn(10); + $orderMock->method('getShortId')->willReturn('SHORT_123'); + + return $orderMock; + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandlerTest.php new file mode 100644 index 000000000..851324dd6 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandlerTest.php @@ -0,0 +1,194 @@ +paymentCapturedHandlerMock = $this->createMock(RazorpayPaymentCapturedHandler::class); + $this->orderPaidHandlerMock = $this->createMock(RazorpayOrderPaidHandler::class); + $this->refundHandlerMock = $this->createMock(RazorpayRefundHandler::class); + $this->paymentFailedHandlerMock = $this->createMock(RazorpayPaymentFailedHandler::class); + $this->paymentAuthorizedHandlerMock = $this->createMock(RazorpayPaymentAuthorizedHandler::class); + $this->verificationServiceMock = $this->createMock(RazorpayPaymentVerificationService::class); + $this->loggerMock = $this->createMock(Logger::class); + $this->cacheMock = $this->createMock(Repository::class); + + $this->handler = new RazorpayWebhookHandler( + $this->paymentCapturedHandlerMock, + $this->orderPaidHandlerMock, + $this->refundHandlerMock, + $this->paymentFailedHandlerMock, + $this->paymentAuthorizedHandlerMock, + $this->verificationServiceMock, + $this->loggerMock, + $this->cacheMock + ); + } + + public function testHandleThrowsExceptionOnInvalidSignature(): void + { + $payload = '{"test":"data"}'; + $signature = 'invalid_sig'; + + $this->verificationServiceMock->method('verifyWebhookSignature') + ->with($payload, $signature) + ->willReturn(false); + + $this->expectException(InvalidSignatureException::class); + + $this->handler->handle($payload, $signature); + } + + public function testHandleReturnsEarlyIfEventAlreadyHandled(): void + { + $payload = $this->createPaymentWebhookJson('payment.captured', 'pay_123'); + $signature = 'valid_sig'; + + $this->verificationServiceMock->method('verifyWebhookSignature')->willReturn(true); + + $this->cacheMock->expects($this->once()) + ->method('has') + ->with('razorpay_webhook_pay_123') + ->willReturn(true); + + $this->paymentCapturedHandlerMock->expects($this->never())->method('handleEvent'); + + $this->handler->handle($payload, $signature); + } + + public function testHandleRoutesToPaymentCapturedHandler(): void + { + $payload = $this->createPaymentWebhookJson('payment.captured', 'pay_123'); + $signature = 'valid_sig'; + + $this->verificationServiceMock->method('verifyWebhookSignature')->willReturn(true); + $this->cacheMock->method('has')->willReturn(false); + + $this->paymentCapturedHandlerMock->expects($this->once())->method('handleEvent'); + $this->cacheMock->expects($this->once())->method('put')->with('razorpay_webhook_pay_123', true); + + $this->handler->handle($payload, $signature); + } + + public function testHandleRoutesToOrderPaidHandler(): void + { + $payload = json_encode([ + 'entity' => 'event', + 'account_id' => 'acc_123', + 'event' => 'order.paid', + 'created_at' => time(), + 'payload' => [ + 'order' => [ + 'entity' => [ + 'id' => 'order_rzp_123', + 'entity' => 'order', + 'amount' => 50000, + 'amount_paid' => 50000, + 'amount_due' => 0, + 'currency' => 'INR', + 'status' => 'paid', + 'receipt' => 'rcpt_1', + 'notes' => [], + 'created_at' => time() + ] + ], + 'payment' => [ + 'entity' => $this->getPaymentEntityData('pay_123') + ] + ] + ]); + $signature = 'valid_sig'; + + $this->verificationServiceMock->method('verifyWebhookSignature')->willReturn(true); + $this->cacheMock->method('has')->willReturn(false); + + $this->orderPaidHandlerMock->expects($this->once())->method('handleEvent'); + $this->cacheMock->expects($this->once())->method('put')->with('razorpay_webhook_order_rzp_123', true); + + $this->handler->handle($payload, $signature); + } + + public function testHandleReturnsEarlyForUnknownEvent(): void + { + $payload = json_encode([ + 'entity' => 'event', + 'account_id' => 'acc_123', + 'event' => 'payment.dispute.created', + 'created_at' => time(), + 'payload' => [] + ]); + $signature = 'valid_sig'; + + $this->verificationServiceMock->method('verifyWebhookSignature')->willReturn(true); + + $this->loggerMock->expects($this->once()) + ->method('debug') + ->with('Unsupported or unknown webhook event', $this->isType('array')); + + $this->handler->handle($payload, $signature); + } + + private function createPaymentWebhookJson(string $event, string $paymentId): string + { + return json_encode([ + 'entity' => 'event', + 'account_id' => 'acc_123', + 'event' => $event, + 'created_at' => time(), + 'payload' => [ + 'payment' => [ + 'entity' => $this->getPaymentEntityData($paymentId) + ] + ] + ]); + } + + private function getPaymentEntityData(string $paymentId): array + { + return [ + 'id' => $paymentId, + 'entity' => 'payment', + 'amount' => 50000, + 'currency' => 'INR', + 'status' => 'captured', + 'method' => 'card', + 'order_id' => 'order_rzp_123', + 'fee' => 100, + 'tax' => 18, + 'description' => 'Test', + 'notes' => [], + 'vpa' => null, + 'email' => 'test@example.com', + 'contact' => '+919999999999', + 'created_at' => time(), + 'error' => null + ]; + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/RefundOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/RefundOrderHandlerTest.php new file mode 100644 index 000000000..a7d17563b --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/RefundOrderHandlerTest.php @@ -0,0 +1,466 @@ +orderRepoMock = $this->createMock(OrderRepositoryInterface::class); + $this->eventRepoMock = $this->createMock(EventRepositoryInterface::class); + $this->mailerMock = $this->createMock(Mailer::class); + $this->orderCancelServiceMock = $this->createMock(OrderCancelService::class); + $this->dbConnectionMock = $this->createMock(ConnectionInterface::class); + $this->stripeRefundServiceMock = $this->createMock(StripePaymentIntentRefundService::class); + $this->stripeClientFactory = $this->createMock(StripeClientFactory::class); + $this->idempotentRefundServiceMock = $this->createMock(IdempotentRefundService::class); + $this->configMock = $this->createMock(Repository::class); + + $this->handler = new RefundOrderHandler( + $this->orderRepoMock, + $this->eventRepoMock, + $this->mailerMock, + $this->orderCancelServiceMock, + $this->dbConnectionMock, + $this->stripeRefundServiceMock, + $this->stripeClientFactory, + $this->idempotentRefundServiceMock, + $this->configMock + ); + } + + private function createOrderWithStripePayment(): OrderDomainObject&MockObject + { + $order = $this->createMock(OrderDomainObject::class); + $order->method('getId')->willReturn(1); + $order->method('getEventId')->willReturn(1); + $order->method('getRefundStatus')->willReturn(null); + $order->method('getStripePayment')->willReturn($this->createMock(StripePaymentDomainObject::class)); + $order->method('getRazorpayOrder')->willReturn(null); + $order->method('getEmail')->willReturn('test@example.com'); + $order->method('getLocale')->willReturn('en'); + $order->method('getCurrency')->willReturn('USD'); + return $order; + } + + private function createOrderWithRazorpayPayment(): OrderDomainObject&MockObject + { + $order = $this->createMock(OrderDomainObject::class); + $order->method('getId')->willReturn(2); + $order->method('getEventId')->willReturn(1); + $order->method('getRefundStatus')->willReturn(null); + $order->method('getStripePayment')->willReturn(null); + $order->method('getRazorpayOrder')->willReturn($this->createMock(RazorpayOrderDomainObject::class)); + $order->method('getEmail')->willReturn('test@example.com'); + $order->method('getLocale')->willReturn('en'); + $order->method('getCurrency')->willReturn('INR'); + return $order; + } + + private function createEvent(): EventDomainObject&MockObject + { + $organizerMock = $this->createMock(OrganizerDomainObject::class); + $settingsMock = $this->createMock(EventSettingDomainObject::class); + + $event = $this->createMock(EventDomainObject::class); + $event->method('getOrganizer')->willReturn($organizerMock); + $event->method('getEventSettings')->willReturn($settingsMock); + return $event; + } + + public function testRefundsStripeOrderSuccessfully(): void + { + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 1, + amount: 50.00, + cancel_order: false, + notify_buyer: false + ); + + $order = $this->createOrderWithStripePayment(); + $event = $this->createEvent(); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->expects($this->exactly(2)) + ->method('loadRelation') + ->willReturnSelf(); + + $this->orderRepoMock->expects($this->once()) + ->method('findFirstWhere') + ->with(['event_id' => 1, 'id' => 1]) + ->willReturn($order); + + $this->eventRepoMock->expects($this->exactly(2)) + ->method('loadRelation') + ->willReturnSelf(); + + $this->eventRepoMock->expects($this->once()) + ->method('findById') + ->with(1) + ->willReturn($event); + + $this->stripeRefundServiceMock->expects($this->once()) + ->method('refundPayment') + ->with( + $this->callback(fn(MoneyValue $amount) => $amount->toFloat() === 50.00), + $order->getStripePayment() + ); + + $this->orderRepoMock->expects($this->once()) + ->method('updateFromArray') + ->with(1, $this->callback(fn($attrs) => isset($attrs['refund_status']))) + ->willReturn($order); + + $result = $this->handler->handle($dto); + + $this->assertSame($order, $result); + } + + public function testRefundsRazorpayOrderWithIdempotencyEnabled(): void + { + $this->configMock->method('get') + ->with('refunds.razorpay.idempotency_enabled') + ->willReturn(true); + + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 2, + amount: 100.00, + cancel_order: false, + notify_buyer: false, + provider_options: ['speed' => 'optimum', 'receipt' => 'Refund#123'] + ); + + $order = $this->createOrderWithRazorpayPayment(); + $event = $this->createEvent(); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->expects($this->exactly(2)) + ->method('loadRelation') + ->willReturnSelf(); + + $this->orderRepoMock->expects($this->once()) + ->method('findFirstWhere') + ->with(['event_id' => 1, 'id' => 2]) + ->willReturn($order); + + $this->eventRepoMock->expects($this->exactly(2)) + ->method('loadRelation') + ->willReturnSelf(); + + $this->eventRepoMock->expects($this->once()) + ->method('findById') + ->with(1) + ->willReturn($event); + + $this->idempotentRefundServiceMock->expects($this->once()) + ->method('refundWithIdempotency') + ->with( + $order->getRazorpayOrder(), + $this->callback(fn(MoneyValue $amount) => $amount->toFloat() === 100.00), + ['speed' => 'optimum', 'receipt' => 'Refund#123'], + $this->callback(function ($key) { + return is_string($key) + && str_starts_with($key, 'refund_') + && strlen($key) === 71 + && ctype_xdigit(substr($key, 7)); + }) + ); + + $this->orderRepoMock->expects($this->once()) + ->method('updateFromArray') + ->with(2, $this->callback(fn($attrs) => isset($attrs['refund_status']))) + ->willReturn($order); + + $result = $this->handler->handle($dto); + + $this->assertSame($order, $result); + } + + public function testSkipsIdempotencyKeyWhenDisabled(): void + { + $this->configMock->method('get') + ->with('refunds.razorpay.idempotency_enabled') + ->willReturn(false); + + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 2, + amount: 100.00, + cancel_order: false, + notify_buyer: false + ); + + $order = $this->createOrderWithRazorpayPayment(); + $event = $this->createEvent(); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->orderRepoMock->method('findFirstWhere')->willReturn($order); + $this->eventRepoMock->method('loadRelation')->willReturnSelf(); + $this->eventRepoMock->method('findById')->willReturn($event); + + $this->idempotentRefundServiceMock->expects($this->once()) + ->method('refundWithIdempotency') + ->with( + $order->getRazorpayOrder(), + $this->callback(fn(MoneyValue $amount) => $amount->toFloat() === 100.00), + [], + null + ); + + $this->orderRepoMock->method('updateFromArray')->willReturn($order); + + $this->handler->handle($dto); + } + + public function testThrowsExceptionWhenNoPaymentDataExists(): void + { + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 3, + amount: 10.00, + cancel_order: false, + notify_buyer: false + ); + + $order = $this->createMock(OrderDomainObject::class); + $order->method('getStripePayment')->willReturn(null); + $order->method('getRazorpayOrder')->willReturn(null); + $order->method('getCurrency')->willReturn('USD'); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->orderRepoMock->method('findFirstWhere')->willReturn($order); + $this->eventRepoMock->method('loadRelation')->willReturnSelf(); + $this->eventRepoMock->method('findById')->willReturn($this->createEvent()); + + $this->expectException(RefundNotPossibleException::class); + $this->expectExceptionMessage('There is no payment data associated with this order.'); + + $this->handler->handle($dto); + } + + public function testThrowsExceptionWhenRefundAlreadyPending(): void + { + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 1, + amount: 50.00, + cancel_order: false, + notify_buyer: false + ); + + $order = $this->createMock(OrderDomainObject::class); + $order->method('getId')->willReturn(1); + $order->method('getEventId')->willReturn(1); + $order->method('getRefundStatus')->willReturn(OrderRefundStatus::REFUND_PENDING->name); + $order->method('getStripePayment')->willReturn($this->createMock(StripePaymentDomainObject::class)); + $order->method('getRazorpayOrder')->willReturn(null); + $order->method('getEmail')->willReturn('test@example.com'); + $order->method('getLocale')->willReturn('en'); + $order->method('getCurrency')->willReturn('USD'); + + $event = $this->createEvent(); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->orderRepoMock->method('findFirstWhere')->willReturn($order); + $this->orderRepoMock->method('updateFromArray')->willReturn($order); + + $this->eventRepoMock->method('loadRelation')->willReturnSelf(); + $this->eventRepoMock->method('findById')->willReturn($event); + + $this->expectException(RefundNotPossibleException::class); + $this->expectExceptionMessage('There is already a refund pending for this order.'); + + $this->handler->handle($dto); + } + + public function testCancelsOrderWhenRequested(): void + { + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 1, + amount: 50.00, + cancel_order: true, + notify_buyer: false + ); + + $order = $this->createOrderWithStripePayment(); + $event = $this->createEvent(); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->orderRepoMock->method('findFirstWhere')->willReturn($order); + $this->eventRepoMock->method('loadRelation')->willReturnSelf(); + $this->eventRepoMock->method('findById')->willReturn($event); + + $this->orderCancelServiceMock->expects($this->once()) + ->method('cancelOrder') + ->with($order); + + $this->stripeRefundServiceMock->method('refundPayment'); + $this->orderRepoMock->method('updateFromArray')->willReturn($order); + + $this->handler->handle($dto); + } + + public function testNotifiesBuyerWhenRequested(): void + { + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 1, + amount: 50.00, + cancel_order: false, + notify_buyer: true + ); + + $order = $this->createOrderWithStripePayment(); + $event = $this->createEvent(); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->orderRepoMock->method('findFirstWhere')->willReturn($order); + $this->eventRepoMock->method('loadRelation')->willReturnSelf(); + $this->eventRepoMock->method('findById')->willReturn($event); + + $this->stripeRefundServiceMock->method('refundPayment'); + + $pendingMailMock = $this->createMock(PendingMail::class); + $pendingMailMock->expects($this->once()) + ->method('locale') + ->with('en') + ->willReturnSelf(); + $pendingMailMock->expects($this->once()) + ->method('send') + ->with($this->isInstanceOf(\HiEvents\Mail\Order\OrderRefunded::class)); + + $this->mailerMock->expects($this->once()) + ->method('to') + ->with('test@example.com') + ->willReturn($pendingMailMock); + + $this->orderRepoMock->method('updateFromArray')->willReturn($order); + + $this->handler->handle($dto); + } + + public function testHandlesRazorpayIdempotentConflict(): void + { + $this->configMock->method('get') + ->with('refunds.razorpay.idempotency_enabled', true) + ->willReturn(true); + + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 2, + amount: 100.00, + cancel_order: false, + notify_buyer: false + ); + + $order = $this->createOrderWithRazorpayPayment(); + $event = $this->createEvent(); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->orderRepoMock->method('findFirstWhere')->willReturn($order); + $this->eventRepoMock->method('loadRelation')->willReturnSelf(); + $this->eventRepoMock->method('findById')->willReturn($event); + + + $this->idempotentRefundServiceMock->method('refundWithIdempotency') + ->willThrowException(new RefundNotPossibleException('A refund for this order is already being processed.')); + + $this->expectException(RefundNotPossibleException::class); + $this->expectExceptionMessage('already being processed'); + + $this->handler->handle($dto); + } + + public function testThrowsResourceNotFoundExceptionWhenOrderNotFound(): void + { + $dto = new RefundOrderDTO( + event_id: 1, + order_id: 999, + amount: 50.00, + cancel_order: false, + notify_buyer: false + ); + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + $this->orderRepoMock->method('findFirstWhere')->willReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($dto); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/IdempotentRefundServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/IdempotentRefundServiceTest.php new file mode 100644 index 000000000..74dcfa7af --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/IdempotentRefundServiceTest.php @@ -0,0 +1,142 @@ +attemptRepoMock = $this->createMock(RefundAttemptRepositoryInterface::class); + $this->refundServiceMock = $this->createMock(RazorpayPaymentRefundService::class); + $this->dbConnectionMock = $this->createMock(ConnectionInterface::class); + + $this->service = new IdempotentRefundService( + $this->attemptRepoMock, + $this->refundServiceMock, + $this->dbConnectionMock + ); + } + + public function testFirstAttemptCreatesAndReturnsRefund(): void + { + // Use RazorpayOrderDomainObject to match getPaymentType() + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $payment->method('getId')->willReturn(1); + $amount = MoneyValue::fromFloat(100, 'INR'); + $options = ['speed' => 'optimum']; + $key = 'test-key'; + + $this->dbConnectionMock->expects($this->once()) + ->method('transaction') + ->willReturnCallback(fn($callback) => $callback()); + + $this->attemptRepoMock->expects($this->once()) + ->method('findByIdempotencyKey') + ->with($key) + ->willReturn(null); + + $attemptMock = $this->createMock(RefundAttemptDomainObject::class); + $attemptMock->method('getId')->willReturn(1); + + $this->attemptRepoMock->expects($this->once()) + ->method('createAttempt') + ->with($key, 1, 'razorpay', ['amount' => 10000, 'options' => $options]) + ->willReturn($attemptMock); + + $expectedResult = (object)['id' => 'refund_123']; + $this->refundServiceMock->expects($this->once()) + ->method('refundPayment') + ->with($payment, 10000, $key, $options) + ->willReturn($expectedResult); + + $this->attemptRepoMock->expects($this->once()) + ->method('markSucceeded') + ->with(1, (array)$expectedResult); + + $result = $this->service->refundWithIdempotency($payment, $amount, $options, $key); + + $this->assertSame($expectedResult, $result); + } + + public function testReturnsStoredResponseIfAlreadySucceeded(): void + { + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $amount = MoneyValue::fromFloat(100, 'INR'); + $options = []; + $key = 'test-key'; + + $this->dbConnectionMock->method('transaction')->willReturnCallback(fn($c) => $c()); + + $attempt = $this->createMock(RefundAttemptDomainObject::class); + $attempt->method('getStatus')->willReturn('succeeded'); + $attempt->method('getResponseData')->willReturn(['id' => 'refund_123']); + + $this->attemptRepoMock->method('findByIdempotencyKey')->with($key)->willReturn($attempt); + + $this->refundServiceMock->expects($this->never())->method('refundPayment'); + + $result = $this->service->refundWithIdempotency($payment, $amount, $options, $key); + + $this->assertEquals((object)['id' => 'refund_123'], $result); + } + + public function testThrowsIfPreviousAttemptFailed(): void + { + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $amount = MoneyValue::fromFloat(100, 'INR'); + $options = []; + $key = 'test-key'; + + $this->dbConnectionMock->method('transaction')->willReturnCallback(fn($c) => $c()); + + $attempt = $this->createMock(RefundAttemptDomainObject::class); + $attempt->method('getStatus')->willReturn('failed'); + $attempt->method('getId')->willReturn(1); + + $this->attemptRepoMock->method('findByIdempotencyKey')->with($key)->willReturn($attempt); + $this->attemptRepoMock->expects($this->once())->method('incrementAttempts')->with(1); + + $this->expectException(RefundNotPossibleException::class); + $this->expectExceptionMessage('Previous refund attempt failed. Please retry.'); + + $this->service->refundWithIdempotency($payment, $amount, $options, $key); + } + + public function testThrowsIfAlreadyPending(): void + { + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $amount = MoneyValue::fromFloat(100, 'INR'); + $options = []; + $key = 'test-key'; + + $this->dbConnectionMock->method('transaction')->willReturnCallback(fn($c) => $c()); + + $attempt = $this->createMock(RefundAttemptDomainObject::class); + $attempt->method('getStatus')->willReturn('pending'); + + $this->attemptRepoMock->method('findByIdempotencyKey')->with($key)->willReturn($attempt); + + $this->expectException(RefundNotPossibleException::class); + $this->expectExceptionMessage('already being processed'); + + $this->service->refundWithIdempotency($payment, $amount, $options, $key); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandlerTest.php new file mode 100644 index 000000000..f70d1b755 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayOrderPaidHandlerTest.php @@ -0,0 +1,275 @@ +orderRepoMock = $this->createMock(OrderRepositoryInterface::class); + $this->razorpayOrdersRepoMock = $this->createMock(RazorpayOrdersRepositoryInterface::class); + $this->affiliateRepoMock = $this->createMock(AffiliateRepositoryInterface::class); + $this->quantityUpdateServiceMock = $this->createMock(ProductQuantityUpdateService::class); + $this->attendeeRepoMock = $this->createMock(AttendeeRepositoryInterface::class); + $this->dbConnectionMock = $this->createMock(ConnectionInterface::class); + $this->loggerMock = $this->createMock(Logger::class); + $this->cacheMock = $this->createMock(CacheRepository::class); + $this->eventDispatcherMock = $this->createMock(DomainEventDispatcherService::class); + $this->feeServiceMock = $this->createMock(OrderApplicationFeeService::class); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + + + $this->dbConnectionMock->method('transaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + Event::fake(); + + $this->handler = new RazorpayOrderPaidHandler( + $this->orderRepoMock, + $this->razorpayOrdersRepoMock, + $this->affiliateRepoMock, + $this->quantityUpdateServiceMock, + $this->attendeeRepoMock, + $this->dbConnectionMock, + $this->loggerMock, + $this->cacheMock, + $this->eventDispatcherMock, + $this->feeServiceMock + ); + } + + public function testItReturnsEarlyIfEventAlreadyHandled(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->expects($this->once()) + ->method('has') + ->with('razorpay_order_paid_order_rzp_123') + ->willReturn(true); + + $this->dbConnectionMock->expects($this->never())->method('transaction'); + + $this->handler->handleEvent($payload); + } + + public function testItReturnsEarlyIfRazorpayOrderNotFoundInDatabase(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->method('has')->willReturn(false); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('findByRazorpayOrderId') + ->with('order_rzp_123') + ->willReturn(null); + + $this->orderRepoMock->expects($this->never())->method('findById'); + + $this->handler->handleEvent($payload); + } + + public function testItThrowsExceptionIfLocalOrderNotFound(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + + $this->razorpayOrdersRepoMock->method('findByRazorpayOrderId')->willReturn($razorpayOrderMock); + + // Instruct the mock to throw an exception, simulating the repository failing to find the record + $this->orderRepoMock->expects($this->once()) + ->method('findById') + ->with(10) + ->willThrowException(new \Exception('Order not found')); + + // We expect the exception to bubble up and abort the process + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Order not found'); + + // We also strictly verify that NO database updates happen if the order isn't found + $this->razorpayOrdersRepoMock->expects($this->never())->method('updateByOrderId'); + + $this->handler->handleEvent($payload); + } + + public function testItProcessesOrderSuccessfullyAndUpdatesRelatedEntities(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + $this->razorpayOrdersRepoMock->method('findByRazorpayOrderId')->willReturn($razorpayOrderMock); + + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('getPaymentStatus')->willReturn(OrderPaymentStatus::AWAITING_PAYMENT->name); + $orderMock->method('getId')->willReturn(10); + $orderMock->method('getTotalGross')->willReturn(500.00); + $orderMock->method('toArray')->willReturn(['affiliate_id' => 99]); + + $updatedOrderMock = clone $orderMock; + + $this->orderRepoMock->method('findById')->willReturn($orderMock); + $this->orderRepoMock->method('updateFromArray')->willReturn($updatedOrderMock); + + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('updateByOrderId') + ->with(10, $this->callback(fn($data) => $data['status'] === 'captured' && (int) $data['amount'] === 50000)); + + $this->orderRepoMock->expects($this->once()) + ->method('updateFromArray') + ->with(10, [ + 'payment_status' => OrderPaymentStatus::PAYMENT_RECEIVED->name, + 'status' => OrderStatus::COMPLETED->name, + 'payment_provider' => PaymentProviders::RAZORPAY->value, + ]); + + $this->attendeeRepoMock->expects($this->once()) + ->method('updateWhere') + ->with(['status' => AttendeeStatus::ACTIVE->name], ['order_id' => 10, 'status' => AttendeeStatus::AWAITING_PAYMENT->name]); + + $this->quantityUpdateServiceMock->expects($this->once())->method('updateQuantitiesFromOrder'); + + $this->affiliateRepoMock->expects($this->once()) + ->method('incrementSales') + ->with(99, 500.00); + + $this->feeServiceMock->expects($this->once()) + ->method('createOrderApplicationFee') + ->with(10, 100, OrderApplicationFeeStatus::PAID, PaymentProviders::RAZORPAY, 'INR'); + + $this->cacheMock->expects($this->once()) + ->method('put') + ->with('razorpay_order_paid_order_rzp_123', true); + + $this->eventDispatcherMock->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(OrderEvent::class)); + + $this->handler->handleEvent($payload); + + Event::assertDispatched(OrderStatusChangedEvent::class); + } + + public function testItSkipsOrderUpdatesIfAlreadyPaid(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + $this->razorpayOrdersRepoMock->method('findByRazorpayOrderId')->willReturn($razorpayOrderMock); + + + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('getPaymentStatus')->willReturn(OrderPaymentStatus::PAYMENT_RECEIVED->name); + $orderMock->method('getId')->willReturn(10); + + $this->orderRepoMock->method('findById')->willReturn($orderMock); + + $this->orderRepoMock->expects($this->never())->method('updateFromArray'); + $this->attendeeRepoMock->expects($this->never())->method('updateWhere'); + $this->affiliateRepoMock->expects($this->never())->method('incrementSales'); + $this->eventDispatcherMock->expects($this->never())->method('dispatch'); + + $this->feeServiceMock->expects($this->once())->method('createOrderApplicationFee'); + $this->cacheMock->expects($this->once())->method('put'); + + $this->handler->handleEvent($payload); + + Event::assertNotDispatched(OrderStatusChangedEvent::class); + } + + private function createPayload(): RazorpayOrderPaidPayload + { + return new RazorpayOrderPaidPayload( + order: $this->createDummyOrderDTO(), + payment: $this->createDummyPaymentDTO() + ); + } + + private function createDummyOrderDTO(): RazorpayOrderDTO + { + return new RazorpayOrderDTO( + id: 'order_rzp_123', + entity: 'order', + amount: 50000, + amount_paid: 50000, + amount_due: 0, + currency: 'INR', + receipt: 'receipt_123', + status: 'paid', + created_at: time(), + notes: [] + ); + } + + private function createDummyPaymentDTO(): RazorpayPaymentDTO + { + return new RazorpayPaymentDTO( + id: 'pay_123', + entity: 'payment', + amount: 50000, + currency: 'INR', + status: 'captured', + method: 'card', + order_id: 'order_rzp_123', + fee: 100, + tax: 18, + description: 'Test payment', + notes: [], + vpa: null, + email: 'test@example.com', + contact: '+919876543210', + created_at: time(), + error: null + + ); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandlerTest.php new file mode 100644 index 000000000..e5f5442d6 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentAuthorizedHandlerTest.php @@ -0,0 +1,142 @@ +razorpayOrdersRepoMock = $this->createMock(RazorpayOrdersRepositoryInterface::class); + $this->dbConnectionMock = $this->createMock(ConnectionInterface::class); + $this->loggerMock = $this->createMock(Logger::class); + $this->cacheMock = $this->createMock(CacheRepository::class); + + $this->dbConnectionMock->method('transaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $this->handler = new RazorpayPaymentAuthorizedHandler( + $this->razorpayOrdersRepoMock, + $this->dbConnectionMock, + $this->loggerMock, + $this->cacheMock + ); + } + + public function testItReturnsEarlyIfEventAlreadyHandled(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->expects($this->once()) + ->method('has') + ->with('razorpay_authorized_pay_123') + ->willReturn(true); + + $this->dbConnectionMock->expects($this->never())->method('transaction'); + + $this->handler->handleEvent($payload); + } + + public function testItReturnsEarlyIfRazorpayOrderNotFound(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn(null); + $this->razorpayOrdersRepoMock->method('findByOrderId')->willReturn(null); + + $this->razorpayOrdersRepoMock->expects($this->never())->method('updateByOrderId'); + + $this->handler->handleEvent($payload); + } + + public function testItRecordsAuthorizedPaymentSuccessfully(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn($razorpayOrderMock); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('updateByOrderId') + ->with(10, $this->callback(function (array $data) { + return $data['status'] === 'authorized' && + $data['razorpay_payment_id'] === 'pay_123' && + (int) $data['amount'] === 50000; + })); + + $this->cacheMock->expects($this->once()) + ->method('put') + ->with('razorpay_authorized_pay_123', true); + + $this->handler->handleEvent($payload); + } + + public function testItFallsBackToOrderIdIfPaymentIdNotFound(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn(null); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('findByRazorpayOrderId') + ->with('order_rzp_123') + ->willReturn($razorpayOrderMock); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('updateByOrderId') + ->with(10, $this->isType('array')); + + $this->handler->handleEvent($payload); + } + + private function createPayload(): RazorpayPaymentPayload + { + return new RazorpayPaymentPayload( + payment: new RazorpayPaymentDTO( + id: 'pay_123', + entity: 'payment', + amount: 50000, + currency: 'INR', + status: 'authorized', + method: 'card', + order_id: 'order_rzp_123', + fee: 100, + tax: 18, + description: 'Test authorized payment', + notes: [], + vpa: null, + email: 'test@example.com', + contact: '+919876543210', + created_at: time(), + error: null + ) + ); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandlerTest.php new file mode 100644 index 000000000..c50d5033b --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentCapturedHandlerTest.php @@ -0,0 +1,234 @@ +orderRepoMock = $this->createMock(OrderRepositoryInterface::class); + $this->razorpayOrdersRepoMock = $this->createMock(RazorpayOrdersRepositoryInterface::class); + $this->affiliateRepoMock = $this->createMock(AffiliateRepositoryInterface::class); + $this->quantityUpdateServiceMock = $this->createMock(ProductQuantityUpdateService::class); + $this->attendeeRepoMock = $this->createMock(AttendeeRepositoryInterface::class); + $this->dbConnectionMock = $this->createMock(ConnectionInterface::class); + $this->loggerMock = $this->createMock(Logger::class); + $this->cacheMock = $this->createMock(CacheRepository::class); + $this->eventDispatcherMock = $this->createMock(DomainEventDispatcherService::class); + $this->feeServiceMock = $this->createMock(OrderApplicationFeeService::class); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + + $this->dbConnectionMock->method('transaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + Event::fake(); + + $this->handler = new RazorpayPaymentCapturedHandler( + $this->orderRepoMock, + $this->razorpayOrdersRepoMock, + $this->affiliateRepoMock, + $this->quantityUpdateServiceMock, + $this->attendeeRepoMock, + $this->dbConnectionMock, + $this->loggerMock, + $this->cacheMock, + $this->eventDispatcherMock, + $this->feeServiceMock + ); + } + + public function testItReturnsEarlyIfPaymentAlreadyHandled(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->expects($this->once()) + ->method('has') + ->with('razorpay_webhook_payment_pay_123') + ->willReturn(true); + + $this->dbConnectionMock->expects($this->never())->method('transaction'); + + $this->handler->handleEvent($payload); + } + + public function testItReturnsEarlyIfRazorpayOrderNotFoundInDatabase(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->method('has')->willReturn(false); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('findByPaymentId') + ->with('pay_123') + ->willReturn(null); + + $this->orderRepoMock->expects($this->never())->method('findById'); + + $this->handler->handleEvent($payload); + } + + public function testItProcessesPaymentSuccessfullyAndUpdatesOrderAndAffiliate(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('toArray')->willReturn(['order_id' => 10]); + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn($razorpayOrderMock); + + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('toArray')->willReturn([ + 'payment_status' => OrderPaymentStatus::AWAITING_PAYMENT->name, + 'affiliate_id' => 99 + ]); + $orderMock->method('getId')->willReturn(10); + $orderMock->method('getTotalGross')->willReturn(500.00); + $orderMock->method('getCurrency')->willReturn('INR'); + + $updatedOrderMock = clone $orderMock; + + $this->orderRepoMock->method('findById')->willReturn($orderMock); + $this->orderRepoMock->method('updateFromArray')->willReturn($updatedOrderMock); + + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('updateByOrderId') + ->with(10, $this->callback(fn($data) => $data['status'] === 'captured' && (int)$data['amount'] === 50000)); + + $this->orderRepoMock->expects($this->once()) + ->method('updateFromArray') + ->with(10, [ + 'payment_status' => OrderPaymentStatus::PAYMENT_RECEIVED->name, + 'status' => OrderStatus::COMPLETED->name, + 'payment_provider' => PaymentProviders::RAZORPAY->value, + ]); + + $this->attendeeRepoMock->expects($this->once()) + ->method('updateWhere') + ->with(['status' => AttendeeStatus::ACTIVE->name], ['order_id' => 10, 'status' => AttendeeStatus::AWAITING_PAYMENT->name]); + + $this->quantityUpdateServiceMock->expects($this->once())->method('updateQuantitiesFromOrder'); + + $this->affiliateRepoMock->expects($this->once()) + ->method('incrementSales') + ->with(99, 500.00); + + $this->feeServiceMock->expects($this->once()) + ->method('createOrderApplicationFee') + ->with(10, 100, OrderApplicationFeeStatus::PAID, PaymentProviders::RAZORPAY, 'INR'); + + $this->cacheMock->expects($this->once()) + ->method('put') + ->with('razorpay_webhook_payment_pay_123', true); + + $this->eventDispatcherMock->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(OrderEvent::class)); + + + $this->handler->handleEvent($payload); + + Event::assertDispatched(OrderStatusChangedEvent::class); + } + + public function testItSkipsOrderUpdatesIfAlreadyPaid(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('toArray')->willReturn(['order_id' => 10]); + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn($razorpayOrderMock); + + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('toArray')->willReturn([ + 'payment_status' => OrderPaymentStatus::PAYMENT_RECEIVED->name, + ]); + $orderMock->method('getId')->willReturn(10); + $orderMock->method('getCurrency')->willReturn('INR'); + + $this->orderRepoMock->method('findById')->willReturn($orderMock); + + $this->orderRepoMock->expects($this->never())->method('updateFromArray'); + $this->attendeeRepoMock->expects($this->never())->method('updateWhere'); + $this->affiliateRepoMock->expects($this->never())->method('incrementSales'); + $this->eventDispatcherMock->expects($this->never())->method('dispatch'); + + $this->feeServiceMock->expects($this->once())->method('createOrderApplicationFee'); + $this->cacheMock->expects($this->once())->method('put'); + + $this->handler->handleEvent($payload); + + Event::assertNotDispatched(OrderStatusChangedEvent::class); + } + + + + + + private function createPayload(): RazorpayPaymentPayload + { + $paymentDTO = new RazorpayPaymentDTO( + id: 'pay_123', + entity: 'payment', + amount: 50000, + currency: 'INR', + status: 'captured', + method: 'card', + order_id: 'order_123', + fee: 100, + tax: 18, + description: 'Test payment', + notes: [], + vpa: null, + email: 'test@example.com', + contact: '+919876543210', + created_at: null, + error: null + ); + + return new RazorpayPaymentPayload($paymentDTO); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandlerTest.php new file mode 100644 index 000000000..1a91d9160 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayPaymentFailedHandlerTest.php @@ -0,0 +1,149 @@ +razorpayOrdersRepoMock = $this->createMock(RazorpayOrdersRepositoryInterface::class); + $this->dbConnectionMock = $this->createMock(ConnectionInterface::class); + $this->loggerMock = $this->createMock(Logger::class); + $this->cacheMock = $this->createMock(CacheRepository::class); + + $this->dbConnectionMock->method('transaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $this->handler = new RazorpayPaymentFailedHandler( + $this->razorpayOrdersRepoMock, + $this->dbConnectionMock, + $this->loggerMock, + $this->cacheMock + ); + } + + public function testItReturnsEarlyIfEventAlreadyHandled(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->expects($this->once()) + ->method('has') + ->with('razorpay_failed_pay_123') + ->willReturn(true); + + $this->dbConnectionMock->expects($this->never())->method('transaction'); + + $this->handler->handleEvent($payload); + } + + public function testItReturnsEarlyIfRazorpayOrderNotFound(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn(null); + $this->razorpayOrdersRepoMock->method('findByRazorpayOrderId')->willReturn(null); + + $this->razorpayOrdersRepoMock->expects($this->never())->method('updateByOrderId'); + + $this->handler->handleEvent($payload); + } + + public function testItRecordsFailedPaymentSuccessfully(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn($razorpayOrderMock); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('updateByOrderId') + ->with(10, $this->callback(function (array $data) { + return $data['status'] === 'failed' && + $data['failure_reason'] === 'Bad PIN' && + $data['error_code'] === 'BAD_PIN'; + })); + + $this->cacheMock->expects($this->once()) + ->method('put') + ->with('razorpay_failed_pay_123', true); + + $this->handler->handleEvent($payload); + } + + public function testItFallsBackToOrderIdIfPaymentIdNotFound(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn(null); + $this->razorpayOrdersRepoMock->method('findByRazorpayOrderId') + ->with('order_rzp_123') + ->willReturn($razorpayOrderMock); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('updateByOrderId') + ->with(10, $this->isType('array')); + + $this->handler->handleEvent($payload); + } + + private function createPayload(): RazorpayPaymentPayload + { + $error = new RazorpayErrorDTO( + code: 'BAD_PIN', + description: 'Bad PIN', + source: 'bank', + step: 'payment_authentication', + reason: 'payment_failed', + ); + + return new RazorpayPaymentPayload( + payment: new RazorpayPaymentDTO( + id: 'pay_123', + entity: 'payment', + amount: 50000, + currency: 'INR', + status: 'failed', + method: 'card', + order_id: 'order_rzp_123', + fee: 0, + tax: 0, + description: 'Test failed payment', + notes: [], + vpa: null, + email: 'test@example.com', + contact: '+919876543210', + created_at: time(), + error: $error + ) + ); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandlerTest.php new file mode 100644 index 000000000..dc41653e8 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayRefundHandlerTest.php @@ -0,0 +1,204 @@ +orderRepoMock = $this->createMock(OrderRepositoryInterface::class); + $this->razorpayOrdersRepoMock = $this->createMock(RazorpayOrdersRepositoryInterface::class); + $this->refundRepoMock = $this->createMock(OrderRefundRepositoryInterface::class); + $this->dbConnectionMock = $this->createMock(ConnectionInterface::class); + $this->loggerMock = $this->createMock(Logger::class); + $this->cacheMock = $this->createMock(CacheRepository::class); + + $this->orderRepoMock->method('loadRelation')->willReturnSelf(); + + $this->dbConnectionMock->method('transaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $this->handler = new RazorpayRefundHandler( + $this->orderRepoMock, + $this->razorpayOrdersRepoMock, + $this->refundRepoMock, + $this->dbConnectionMock, + $this->loggerMock, + $this->cacheMock + ); + } + + public function testItReturnsEarlyIfEventAlreadyHandled(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->expects($this->once()) + ->method('has') + ->with('razorpay_refund_rfnd_123') + ->willReturn(true); + + $this->dbConnectionMock->expects($this->never())->method('transaction'); + + $this->handler->handleEvent($payload); + } + + public function testItReturnsEarlyIfRazorpayOrderNotFoundInDatabase(): void + { + $payload = $this->createPayload(); + + $this->cacheMock->method('has')->willReturn(false); + + $this->razorpayOrdersRepoMock->expects($this->once()) + ->method('findByPaymentId') + ->with('pay_123') + ->willReturn(null); + + $this->orderRepoMock->expects($this->never())->method('findById'); + + $this->handler->handleEvent($payload); + } + + public function testItThrowsExceptionIfLocalOrderNotFound(): void + { + $payload = $this->createPayload(); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn($razorpayOrderMock); + + $this->orderRepoMock->expects($this->once()) + ->method('findById') + ->with(10) + ->willThrowException(new Exception('Order not found')); + + $this->refundRepoMock->expects($this->never())->method('create'); + + $this->expectException(Exception::class); + + $this->handler->handleEvent($payload); + } + + public function testItProcessesPartialRefundSuccessfully(): void + { + $payload = $this->createPayload(20000); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn($razorpayOrderMock); + + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('getId')->willReturn(10); + $orderMock->method('getTotalGross')->willReturn(500.00); + + $this->orderRepoMock->method('findById')->willReturn($orderMock); + + $this->refundRepoMock->expects($this->once()) + ->method('getTotalRefundedForOrder') + ->with(10) + ->willReturn(200.00); + + $this->refundRepoMock->expects($this->once()) + ->method('create') + ->with([ + 'order_id' => 10, + 'payment_provider' => 'razorpay', + 'refund_id' => 'rfnd_123', + 'amount' => 200.00, + 'currency' => 'INR', + 'status' => 'processed', + 'reason' => 'customer requested', + 'metadata' => [ + 'razorpay_refund' => $payload->refund->toArray(), + ], + ]); + + $this->orderRepoMock->expects($this->once()) + ->method('updateFromArray') + ->with(10, [ + 'refund_status' => OrderPaymentStatus::PARTIALLY_REFUNDED->name, + 'total_refunded' => 200.00 + ]); + + $this->cacheMock->expects($this->once())->method('put'); + + $this->handler->handleEvent($payload); + } + + public function testItProcessesFullRefundSuccessfully(): void + { + $payload = $this->createPayload(50000); + $this->cacheMock->method('has')->willReturn(false); + + $razorpayOrderMock = $this->createMock(RazorpayOrderDomainObject::class); + $razorpayOrderMock->method('getOrderId')->willReturn(10); + $this->razorpayOrdersRepoMock->method('findByPaymentId')->willReturn($razorpayOrderMock); + + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('getId')->willReturn(10); + $orderMock->method('getTotalGross')->willReturn(500.00); + + $this->orderRepoMock->method('findById')->willReturn($orderMock); + + $this->refundRepoMock->expects($this->once()) + ->method('getTotalRefundedForOrder') + ->with(10) + ->willReturn(500.00); + + $this->orderRepoMock->expects($this->once()) + ->method('updateFromArray') + ->with(10, [ + 'refund_status' => OrderPaymentStatus::REFUNDED->name, + 'total_refunded' => 500.00 + ]); + + $this->handler->handleEvent($payload); + } + + private function createPayload(int $amountInPaise = 50000): RazorpayRefundPayload + { + return new RazorpayRefundPayload( + refund: new RazorpayRefundDTO( + id: 'rfnd_123', + entity: 'refund', + amount: $amountInPaise, + currency: 'INR', + payment_id: 'pay_123', + status: 'processed', + created_at: time(), + notes: ['reason' => 'customer requested'], + fee: 10, + tax: 13 + ) + ); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayOrderCreationServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayOrderCreationServiceTest.php new file mode 100644 index 000000000..296ce41cb --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayOrderCreationServiceTest.php @@ -0,0 +1,162 @@ +loggerMock = $this->createMock(LoggerInterface::class); + $this->configMock = $this->createMock(Repository::class); + $this->dbMock = $this->createMock(ConnectionInterface::class); // <-- Perfectly mockable + $this->feeServiceMock = $this->createMock(OrderApplicationFeeCalculationService::class); + $this->factoryMock = $this->createMock(RazorpayClientFactory::class); + $this->razorpayClientMock = $this->createMock(RazorpayClientInterface::class); + + $this->factoryMock->method('create')->willReturn($this->razorpayClientMock); + + $this->service = new RazorpayOrderCreationService( + $this->loggerMock, + $this->configMock, + $this->dbMock, + $this->feeServiceMock, + $this->factoryMock + ); + } + + #[DataProvider('currencyDataProvider')] + public function testItCalculatesAmountCorrectlyBasedOnCurrency( + string $currencyCode, + int $minorUnit, + float $floatAmount, + int $expectedRazorpayAmount + ): void { + $dtoMock = $this->createMockedRequestDTO($currencyCode, $minorUnit, $floatAmount); + + $expectedRazorpayResponse = (object) [ + 'id' => 'order_123', + 'amount' => $expectedRazorpayAmount, + 'currency' => $currencyCode, + 'receipt' => 'SHORT_123' + ]; + + // Database Expectations + $this->dbMock->expects($this->once())->method('beginTransaction'); + $this->dbMock->expects($this->once())->method('commit'); + $this->dbMock->expects($this->never())->method('rollBack'); + + $this->configMock->method('get') + ->with('services.razorpay.key_id') + ->willReturn('test_key_id'); + + $this->razorpayClientMock->expects($this->once()) + ->method('createOrder') + ->with($this->callback(function (array $orderData) use ($expectedRazorpayAmount, $currencyCode) { + return $orderData['amount'] === $expectedRazorpayAmount + && $orderData['currency'] === $currencyCode + && $orderData['receipt'] === 'SHORT_123'; + })) + ->willReturn($expectedRazorpayResponse); + + $response = $this->service->createOrder($dtoMock); + + $this->assertInstanceOf(CreateRazorpayOrderResponseDTO::class, $response); + $this->assertEquals('order_123', $response->id); + } + + public function testItRollsBackAndThrowsCustomExceptionOnRazorpayError(): void + { + $dtoMock = $this->createMockedRequestDTO(); + + $this->dbMock->expects($this->once())->method('beginTransaction'); + $this->dbMock->expects($this->never())->method('commit'); + $this->dbMock->expects($this->once())->method('rollBack'); + + $this->razorpayClientMock->expects($this->once()) + ->method('createOrder') + ->willThrowException(new BadRequestError('Invalid amount', 400, 400)); + + $this->expectException(CreateOrderFailedException::class); + + $this->service->createOrder($dtoMock); + } + + public function testItRollsBackAndRethrowsGenericException(): void + { + $dtoMock = $this->createMockedRequestDTO(); + + $this->dbMock->expects($this->once())->method('beginTransaction'); + $this->dbMock->expects($this->never())->method('commit'); + $this->dbMock->expects($this->once())->method('rollBack'); + + $this->razorpayClientMock->expects($this->once()) + ->method('createOrder') + ->willThrowException(new Exception('Database connection lost')); + + $this->expectException(Exception::class); + + $this->service->createOrder($dtoMock); + } + + private function createMockedRequestDTO( + string $currencyCode = 'INR', + int $minorUnit = 50000, + float $floatAmount = 500.00 + ): CreateRazorpayOrderRequestDTO { + $amountMock = $this->createMock(MoneyValue::class); + $amountMock->method('toMinorUnit')->willReturn($minorUnit); + $amountMock->method('toFloat')->willReturn($floatAmount); + + $orderMock = $this->createMock(OrderDomainObject::class); + $orderMock->method('getShortId')->willReturn('SHORT_123'); + $orderMock->method('getId')->willReturn(1); + $orderMock->method('getEventId')->willReturn(99); + + $accountMock = $this->createMock(AccountDomainObject::class); + $accountMock->method('getId')->willReturn(5); + + return new CreateRazorpayOrderRequestDTO( + amount: $amountMock, + currencyCode: $currencyCode, + account: $accountMock, + order: $orderMock + ); + } + + public static function currencyDataProvider(): array + { + return [ + 'INR uses minor unit' => ['INR', 50000, 500.00, 50000], + 'USD calculates float' => ['USD', 5000, 45.50, 4550], + ]; + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayPaymentRefundServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayPaymentRefundServiceTest.php new file mode 100644 index 000000000..f4c71aec2 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayPaymentRefundServiceTest.php @@ -0,0 +1,125 @@ +clientFactoryMock = $this->createMock(RazorpayClientFactory::class); + $this->clientMock = $this->createMock(RazorpayClientInterface::class); + $this->logger = $this->createMock(Logger::class); + $this->service = new RazorpayPaymentRefundService($this->clientFactoryMock, $this->logger); + } + + public function testRefundPaymentSuccess(): void + { + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $payment->method('getRazorpayPaymentId')->willReturn('pay_abc123'); + + $this->clientFactoryMock->method('create')->willReturn($this->clientMock); + + $this->clientMock->expects($this->once()) + ->method('refundPayment') + ->with( + ['payment_id' => 'pay_abc123', 'amount' => 10000], + null + ) + ->willReturn((object) ['id' => 'refund_xyz']); + + $result = $this->service->refundPayment($payment, 10000); + + $this->assertEquals('refund_xyz', $result->id); + } + + public function testRefundPaymentThrowsWhenPaymentIdMissing(): void + { + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $payment->method('getRazorpayPaymentId')->willReturn(null); + + $this->expectException(RefundNotPossibleException::class); + $this->expectExceptionMessage('No Razorpay payment ID found for this order.'); + + $this->service->refundPayment($payment, 10000); + } + + public function testRefundPaymentPassesIdempotencyKeyAndOptions(): void + { + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $payment->method('getRazorpayPaymentId')->willReturn('pay_abc123'); + + $this->clientFactoryMock->method('create')->willReturn($this->clientMock); + + $this->clientMock->expects($this->once()) + ->method('refundPayment') + ->with( + [ + 'payment_id' => 'pay_abc123', + 'amount' => 10000, + 'speed' => 'optimum', + 'receipt' => 'Receipt#123', + 'notes' => ['reason' => 'Customer request'] + ], + 'idempotency-key-123' + ) + ->willReturn((object) ['id' => 'refund_xyz']); + + $result = $this->service->refundPayment( + $payment, + 10000, + 'idempotency-key-123', + [ + 'speed' => 'optimum', + 'receipt' => 'Receipt#123', + 'notes' => ['reason' => 'Customer request'] + ] + ); + + $this->assertEquals('refund_xyz', $result->id); + } + + public function testRefundPaymentMergesOptionsCorrectly(): void + { + $payment = $this->createMock(RazorpayOrderDomainObject::class); + $payment->method('getRazorpayPaymentId')->willReturn('pay_abc123'); + + $this->clientFactoryMock->method('create')->willReturn($this->clientMock); + + $this->clientMock->expects($this->once()) + ->method('refundPayment') + ->with( + [ + 'payment_id' => 'pay_abc123', + 'amount' => 5000, + 'speed' => 'optimum', + ], + null + ) + ->willReturn((object) ['id' => 'refund_xyz']); + + $result = $this->service->refundPayment( + $payment, + 5000, + null, + ['speed' => 'optimum'] + ); + + $this->assertEquals('refund_xyz', $result->id); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationServiceTest.php new file mode 100644 index 000000000..466e9eb3e --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Razorpay/RazorpayPaymentVerificationServiceTest.php @@ -0,0 +1,154 @@ +loggerMock = $this->createMock(LoggerInterface::class); + $this->configMock = $this->createMock(Repository::class); + $this->clientFactoryMock = $this->createMock(RazorpayClientFactory::class); + + $this->service = new RazorpayPaymentVerificationService( + $this->loggerMock, + $this->configMock, + $this->clientFactoryMock + ); + } + + public function testVerifyPaymentSignatureReturnsTrueOnSuccess(): void + { + $orderId = 'order_123'; + $paymentId = 'pay_123'; + $secret = 'test_secret'; + $signature = hash_hmac('sha256', $orderId . '|' . $paymentId, $secret); + + $this->configMock->method('get') + ->with('services.razorpay.key_secret') + ->willReturn($secret); + + $dto = new VerifyRazorpayPaymentDTO( + razorpay_order_id: $orderId, + razorpay_payment_id: $paymentId, + razorpay_signature: $signature + ); + + $result = $this->service->verifyPaymentSignature($dto); + + $this->assertTrue($result); + } + + public function testVerifyPaymentSignatureThrowsExceptionOnFailure(): void + { + $this->configMock->method('get') + ->with('services.razorpay.key_secret') + ->willReturn('test_secret'); + + $dto = new VerifyRazorpayPaymentDTO( + razorpay_order_id: 'order_123', + razorpay_payment_id: 'pay_123', + razorpay_signature: 'invalid_signature' + ); + + $this->loggerMock->expects($this->once()) + ->method('error') + ->with('Razorpay signature verification failed', $this->isType('array')); + + $this->expectException(InvalidSignatureException::class); + + $this->service->verifyPaymentSignature($dto); + } + + public function testVerifyWebhookSignatureReturnsTrueOnMatch(): void + { + $payload = '{"event":"payment.captured"}'; + $secret = 'webhook_secret'; + $signature = hash_hmac('sha256', $payload, $secret); + + $this->configMock->method('get') + ->with('services.razorpay.webhook_secret') + ->willReturn($secret); + + $result = $this->service->verifyWebhookSignature($payload, $signature); + + $this->assertTrue($result); + } + + public function testVerifyWebhookSignatureReturnsFalseOnMismatch(): void + { + $this->configMock->method('get') + ->with('services.razorpay.webhook_secret') + ->willReturn('secret'); + + $result = $this->service->verifyWebhookSignature('payload', 'wrong_signature'); + + $this->assertFalse($result); + } + + public function testFetchPaymentDetailsReturnsMappedArray(): void + { + $paymentId = 'pay_123'; + $paymentData = (object) [ + 'id' => $paymentId, + 'amount' => 50000, + 'currency' => 'INR', + 'status' => 'captured', + 'order_id' => 'order_123', + 'method' => 'card', + 'created_at' => 123456789 + ]; + + $clientMock = $this->createMock(\HiEvents\Services\Infrastructure\Razorpay\RazorpayClientInterface::class); + + $clientMock->expects($this->once()) + ->method('fetchPayment') + ->with($paymentId) + ->willReturn($paymentData); + + $this->clientFactoryMock->method('create')->willReturn($clientMock); + + $result = $this->service->fetchPaymentDetails($paymentId); + + $this->assertEquals([ + 'id' => $paymentId, + 'amount' => 50000, + 'currency' => 'INR', + 'status' => 'captured', + 'order_id' => 'order_123', + 'method' => 'card', + 'created_at' => 123456789 + ], $result); + } + + public function testFetchPaymentDetailsLogsAndThrowsExceptionOnFailure(): void + { + $this->clientFactoryMock->method('create') + ->willThrowException(new Exception('API Error')); + + $this->loggerMock->expects($this->once()) + ->method('error') + ->with('Failed to fetch Razorpay payment details', $this->isType('array')); + + $this->expectException(Exception::class); + + $this->service->fetchPaymentDetails('pay_123'); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayApiClientTest.php b/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayApiClientTest.php new file mode 100644 index 000000000..88342e26d --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayApiClientTest.php @@ -0,0 +1,184 @@ +apiMock = $this->createMock(Api::class); + $this->client = new RazorpayApiClient('test_id', 'test_secret', $this->apiMock); + } + + public function testItCanCreateAnOrderSuccessfully(): void + { + $orderData = ['amount' => 50000, 'currency' => 'INR', 'receipt' => 'receipt#1']; + $expectedResponse = (object) ['id' => 'order_123', 'status' => 'created']; + + $orderMock = $this->createMock(Order::class); + $orderMock->expects($this->once()) + ->method('create') + ->with($orderData) + ->willReturn($expectedResponse); + + $this->apiMock->order = $orderMock; + + $result = $this->client->createOrder($orderData); + + $this->assertEquals('order_123', $result->id); + $this->assertEquals('created', $result->status); + } + + public function testItThrowsExceptionWhenOrderCreationFailed(): void + { + $badOrderData = ['amount' => 0, 'currency' => 'INR']; + $orderMock = $this->createMock(Order::class); + + $orderMock->expects($this->once()) + ->method('create') + ->with($badOrderData) + ->willThrowException(new BadRequestError('Order amount less than minimum amount allowed', 400, 400)); + + $this->apiMock->order = $orderMock; + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Order amount less than minimum amount allowed'); + + $this->client->createOrder($badOrderData); + } + + public function testItCanFetchAPaymentSuccessfully(): void + { + $paymentId = 'pay_123'; + $expectedResponse = (object) ['id' => 'pay_123', 'status' => 'captured', 'method' => 'upi']; + + $paymentMock = $this->createMock(Payment::class); + $paymentMock->expects($this->once()) + ->method('fetch') + ->with($paymentId) + ->willReturn($expectedResponse); + + $this->apiMock->payment = $paymentMock; + + $result = $this->client->fetchPayment($paymentId); + + $this->assertEquals('pay_123', $result->id); + $this->assertEquals('captured', $result->status); + $this->assertEquals('upi', $result->method); + } + + public function testItThrowsExceptionWhenPaymentIsInvalid(): void + { + $paymentMock = $this->createMock(Payment::class); + $paymentMock->expects($this->once()) + ->method('fetch') + ->willThrowException(new BadRequestError('Invalid ID', 400, 400)); + + $this->apiMock->payment = $paymentMock; + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Invalid ID'); + + $this->client->fetchPayment('invalid_id'); + } + + public function testItCanRefundAPaymentSuccessfullyWithoutIdempotencyKey(): void + { + $paymentId = 'pay_123'; + $refundData = ['amount' => 5000]; + $expectedResponse = (object) ['id' => 'refund_123', 'status' => 'processed']; + + $paymentMock = $this->createMock(Payment::class); + $paymentMock->expects($this->once()) + ->method('refund') + ->with($refundData, null) + ->willReturn($expectedResponse); + + $paymentMock->expects($this->once()) + ->method('fetch') + ->with($paymentId) + ->willReturn($paymentMock); + + $this->apiMock->payment = $paymentMock; + + $result = $this->client->refundPayment([ + 'payment_id' => $paymentId, + 'amount' => 5000, + ]); + + $this->assertEquals('refund_123', $result->id); + $this->assertEquals('processed', $result->status); + } + + public function testItCanRefundAPaymentSuccessfullyWithIdempotencyKey(): void + { + $paymentId = 'pay_123'; + $refundData = ['amount' => 5000, 'speed' => 'optimum', 'receipt' => 'receipt#1']; + $idempotencyKey = 'idempotency-key-123'; + $expectedResponse = (object) ['id' => 'refund_123', 'status' => 'processed']; + + $paymentMock = $this->createMock(Payment::class); + $paymentMock->expects($this->once()) + ->method('refund') + ->with($refundData, $idempotencyKey) + ->willReturn($expectedResponse); + + $paymentMock->expects($this->once()) + ->method('fetch') + ->with($paymentId) + ->willReturn($paymentMock); + + $this->apiMock->payment = $paymentMock; + + $result = $this->client->refundPayment([ + 'payment_id' => $paymentId, + 'amount' => 5000, + 'speed' => 'optimum', + 'receipt' => 'receipt#1', + ], $idempotencyKey); + + $this->assertEquals('refund_123', $result->id); + $this->assertEquals('processed', $result->status); + } + + public function testItThrowsExceptionWhenRefundFails(): void + { + $paymentId = 'pay_123'; + $refundData = ['amount' => 5000]; + + $paymentMock = $this->createMock(Payment::class); + + $paymentMock->expects($this->once()) + ->method('refund') + ->with($refundData, null) + ->willThrowException(new BadRequestError('Refund failed', 400, 400)); + + $paymentMock->expects($this->once()) + ->method('fetch') + ->with($paymentId) + ->willReturn($paymentMock); + + $this->apiMock->payment = $paymentMock; + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Refund failed'); + + $this->client->refundPayment([ + 'payment_id' => $paymentId, + 'amount' => 5000, + ]); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayClientFactoryTest.php b/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayClientFactoryTest.php new file mode 100644 index 000000000..fafcaedaf --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayClientFactoryTest.php @@ -0,0 +1,73 @@ +configMock = $this->createMock(Repository::class); + + $this->factory = new RazorpayClientFactory($this->configMock); + } + + public function testItCreatesClientSuccessfullyWhenConfigured(): void + { + + $this->configMock->method('get')->willReturnCallback(function (string $key) { + if ($key === 'services.razorpay.key_id') { + return 'test_key_id'; + } + if ($key === 'services.razorpay.key_secret') { + return 'test_key_secret'; + } + return null; + }); + + $client = $this->factory->create(); + + $this->assertInstanceOf(RazorpayApiClient::class, $client); + } + + public function testItThrowsExceptionWhenKeyIdIsMissing(): void + { + $this->configMock->method('get')->willReturnCallback(function (string $key) { + if ($key === 'services.razorpay.key_secret') { + return 'test_key_secret'; + } + return null; + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Razorpay credentials not configured.'); + + $this->factory->create(); + } + + public function testItThrowsExceptionWhenKeySecretIsMissing(): void + { + $this->configMock->method('get')->willReturnCallback(function (string $key) { + if ($key === 'services.razorpay.key_id') { + return 'test_key_id'; + } + return ''; + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Razorpay credentials not configured.'); + + $this->factory->create(); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayClientInterfaceTest.php b/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayClientInterfaceTest.php new file mode 100644 index 000000000..fc4ba3ac7 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Razorpay/RazorpayClientInterfaceTest.php @@ -0,0 +1,15 @@ +assertTrue($reflection->hasMethod('refundPayment')); + } +} \ No newline at end of file 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/common/OrdersTable/index.tsx b/frontend/src/components/common/OrdersTable/index.tsx index 1ddaabb58..db9455b4e 100644 --- a/frontend/src/components/common/OrdersTable/index.tsx +++ b/frontend/src/components/common/OrdersTable/index.tsx @@ -132,7 +132,7 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { const ActionMenu = ({order}: { order: Order }) => { const isRefundable = !order.is_free_order && order.status !== 'AWAITING_OFFLINE_PAYMENT' - && order.payment_provider === 'STRIPE' + && ['STRIPE', 'RAZORPAY'].includes(order.payment_provider) && order.refund_status !== 'REFUNDED'; return ( diff --git a/frontend/src/components/modals/CancelOrderModal/index.tsx b/frontend/src/components/modals/CancelOrderModal/index.tsx index 24c9cdb91..b7a0f63aa 100644 --- a/frontend/src/components/modals/CancelOrderModal/index.tsx +++ b/frontend/src/components/modals/CancelOrderModal/index.tsx @@ -26,7 +26,7 @@ export const CancelOrderModal = ({onClose, orderId}: RefundOrderModalProps) => { const isRefundable = order && !order.is_free_order && order.status !== 'AWAITING_OFFLINE_PAYMENT' - && order.payment_provider === 'STRIPE' + && ['STRIPE', 'RAZORPAY'].includes(order.payment_provider) && order.refund_status !== 'REFUNDED'; const handleCancelOrder = () => { 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..e7e77454c --- /dev/null +++ b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx @@ -0,0 +1,241 @@ +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"; +import { Badge, Card, Divider, Group, Stack, Text, Title } from "@mantine/core"; +import { Image } from "@mantine/core"; +import { AxiosError } from "axios"; + +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) { + const errorMessage = (razorpayOrderError as AxiosError)?.response?.data?.message ?? t`Something went wrong`; + + return ( + + + + ); + } + + if (!isRazorpayFetched) { + return ; + } + + return ( + <> + + {t`Payment`} + + {t`You will be redirected to Razorpay to securely complete your payment.`} + + + + {isLoading && } + + {/* Payment Method Card */} + + + {/* Method Header */} + + + Razorpay +
+ Razorpay + + Cards • UPI • NetBanking • Wallets + +
+
+ + + {t`Secure`} + +
+ + + + {/* Order Summary */} + {razorpayData && ( + + + + {t`Order ID`} + + + {order?.short_id} + + + + + + {t`Amount`} + + + {new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: razorpayData.currency, + }).format(razorpayData.amount / 100)} + + + + )} +
+
+ + {/* Footer Hint */} + + {t`Payments are processed securely by Razorpay using industry-standard encryption.`} + + +); +}; \ 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..d03d6c33f 100644 --- a/frontend/src/components/routes/product-widget/Payment/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/index.tsx @@ -1,49 +1,53 @@ -import React, {useState} from "react"; -import {useNavigate, useParams} from "react-router"; -import {useGetEventPublic} from "../../../../queries/useGetEventPublic.ts"; -import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent"; -import {StripePaymentMethod} from "./PaymentMethods/Stripe"; -import {OfflinePaymentMethod} from "./PaymentMethods/Offline"; -import {Event} from "../../../../types.ts"; -import {Button, Group, Text} from "@mantine/core"; -import {IconBuildingBank, IconLock, IconWallet} from "@tabler/icons-react"; -import {formatCurrency} from "../../../../utilites/currency.ts"; -import {t, Trans} from "@lingui/macro"; -import {useGetOrderPublic} from "../../../../queries/useGetOrderPublic.ts"; +import React, { useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { useGetEventPublic } from "../../../../queries/useGetEventPublic.ts"; +import { CheckoutContent } from "../../../layouts/Checkout/CheckoutContent"; +import { StripePaymentMethod } from "./PaymentMethods/Stripe"; +import { OfflinePaymentMethod } from "./PaymentMethods/Offline"; +import { Event } from "../../../../types.ts"; +import { Button, Group, Text } from "@mantine/core"; +import { IconBuildingBank, IconLock, IconWallet } from "@tabler/icons-react"; +import { formatCurrency } from "../../../../utilites/currency.ts"; +import { t, Trans } from "@lingui/macro"; +import { useGetOrderPublic } from "../../../../queries/useGetOrderPublic.ts"; import { useTransitionOrderToOfflinePaymentPublic } from "../../../../mutations/useTransitionOrderToOfflinePaymentPublic.ts"; -import {Card} from "../../../common/Card"; -import {InlineOrderSummary} from "../../../common/InlineOrderSummary"; -import {showError} from "../../../../utilites/notifications.tsx"; -import {getConfig} from "../../../../utilites/config.ts"; +import { Card } from "../../../common/Card"; +import { InlineOrderSummary } from "../../../common/InlineOrderSummary"; +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 { trackEvent, AnalyticsEvents } from "../../../../utilites/analytics.ts"; +import { RazorpayPaymentMethod } from "./PaymentMethods/Razorpay/index.tsx"; const Payment = () => { const navigate = useNavigate(); - const {eventId, orderShortId} = useParams(); - const {data: event, isFetched: isEventFetched} = useGetEventPublic(eventId); - const {data: order, isFetched: isOrderFetched} = useGetOrderPublic(eventId, orderShortId, ['event']); + const { eventId, orderShortId } = useParams(); + const { data: event, isFetched: isEventFetched } = useGetEventPublic(eventId); + 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 ( @@ -94,40 +98,58 @@ const Payment = () => { <> {(event && order) && ( - + )} {isStripeEnabled && ( -
- +
+ +
+ )} + + {isRazorpayEnabled && ( +
+
)} {isOfflineEnabled && ( -
- +
+
)} - {(isStripeEnabled && isOfflineEnabled) && ( + {((isStripeEnabled || isRazorpayEnabled) && isOfflineEnabled) && (
{t`Payment method`}
- + {isStripeEnabled && ( + + )} + {isRazorpayEnabled && ( + + )}
@@ -142,7 +164,7 @@ const Payment = () => { > {order?.is_payment_required ? ( - + {t`Pay`} {formatCurrency(order.total_gross, order.currency)} ) : t`Complete Payment`} diff --git a/frontend/src/queries/useCreateRazorpayOrder.ts b/frontend/src/queries/useCreateRazorpayOrder.ts new file mode 100644 index 000000000..1e4392627 --- /dev/null +++ b/frontend/src/queries/useCreateRazorpayOrder.ts @@ -0,0 +1,23 @@ +import {useQuery} from "@tanstack/react-query"; +import {orderClientPublic} from "../api/order.client.ts"; +import {IdParam} from "../types.ts"; + +export const GET_RAZORPAY_ORDER_PUBLIC_QUERY_KEY = 'getRazorpayOrderPublic'; + +export const useCreateRazorpayOrder = (eventId: IdParam, orderShortId: IdParam) => { + return useQuery({ + queryKey: [GET_RAZORPAY_ORDER_PUBLIC_QUERY_KEY, eventId, orderShortId], + + queryFn: async () => { + const {razorpay_order_id, key_id, amount, currency} = await orderClientPublic.createRazorpayOrder( + Number(eventId), + String(orderShortId), + ); + return {razorpay_order_id, key_id, amount, currency}; + }, + + retry: false, + staleTime: 0, + gcTime: 0 + }); +} \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0e3b809a0..121d36a11 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -181,7 +181,7 @@ export interface Image { export type ImageType = 'EVENT_COVER' | 'EDITOR_IMAGE' | 'ORGANIZER_LOGO' | 'ORGANIZER_COVER' | 'ORGANIZER_IMAGE' | 'TICKET_LOGO'; -export type PaymentProvider = 'STRIPE' | 'OFFLINE'; +export type PaymentProvider = 'STRIPE' | 'RAZORPAY' | 'OFFLINE'; export type AttendeeDetailsCollectionMethod = 'PER_TICKET' | 'PER_ORDER';