From 5266ce8a0963c115a4ef4b3199203733b5dc8c47 Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 15:24:29 +0530 Subject: [PATCH 1/8] Add Stripe integration documentation Add a comprehensive Stripe integration reference at backend/docs/stripe-integration.md. The new doc details end-to-end Stripe handling for Hi.Events including environment variables, dependencies, architecture and checkout flow, database schema (stripe_customers, stripe_payments, stripe_payouts, account_stripe_platforms and order additions), backend infrastructure and domain services (configuration, client factory, payment intent creation/refunds, account sync, payout and fee extraction), application handlers and HTTP actions/routes, webhook security and idempotency, frontend React components and queries, SaaS vs open-source behaviours, multi-platform support, refund and payout reconciliation flows, testing commands and a key files reference. --- backend/docs/stripe-integration.md | 639 +++++++++++++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 backend/docs/stripe-integration.md diff --git a/backend/docs/stripe-integration.md b/backend/docs/stripe-integration.md new file mode 100644 index 0000000000..e9f1e064f9 --- /dev/null +++ b/backend/docs/stripe-integration.md @@ -0,0 +1,639 @@ +# Stripe Payment Integration — Hi.Events + +This document provides a comprehensive reference for how Stripe is integrated into the Hi.Events codebase, covering both the Laravel backend and the React (SSR) frontend. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Dependencies](#dependencies) +3. [Environment Variables](#environment-variables) +4. [Architecture & Flow](#architecture--flow) +5. [Database Schema](#database-schema) +6. [Backend — Infrastructure Layer](#backend--infrastructure-layer) +7. [Backend — Domain Layer](#backend--domain-layer) +8. [Backend — Application Layer (Handlers)](#backend--application-layer-handlers) +9. [Backend — HTTP Layer (Actions & Routes)](#backend--http-layer-actions--routes) +10. [Backend — Webhook Handling](#backend--webhook-handling) +11. [Frontend — React Components & Queries](#frontend--react-components--queries) +12. [SaaS Mode vs. Open-Source Mode](#saas-mode-vs-open-source-mode) +13. [Multi-Platform Support](#multi-platform-support) +14. [Refund Flow](#refund-flow) +15. [Payout Reconciliation](#payout-reconciliation) +16. [Testing](#testing) + +--- + +## Overview + +Hi.Events integrates Stripe as the primary online payment provider. The integration supports: + +- **Payment Intents** for checkout (no legacy charges API) +- **Stripe Connect** for SaaS mode (organizers connect their own Stripe accounts) +- **Webhooks** for asynchronous payment confirmation, refunds, payouts, and account updates +- **Multi-Platform Keys** (e.g., separate Stripe accounts for Canada (`ca`) and Ireland (`ie`) in Hi.Events cloud) +- **Automatic refunds** for expired orders +- **Application fees** charged on top of transactions in SaaS mode +- **EU VAT handling** for application fees + +--- + +## Dependencies + +### Backend (`backend/composer.json`) + +| Package | Version | Purpose | +|---------|---------|---------| +| `stripe/stripe-php` | `^17.0` | Official Stripe PHP SDK | + +### Frontend (`frontend/package.json`) + +| Package | Version | Purpose | +|---------|---------|---------| +| `@stripe/stripe-js` | `^1.54.1` | Stripe.js browser SDK (loads Stripe, creates Elements) | +| `@stripe/react-stripe-js` | `^2.1.1` | React components (``, ``, hooks) | + +--- + +## Environment Variables + +Defined in `backend/.env.example`. In production, set the equivalent non-example values. + +```env +# Default / open-source keys +STRIPE_PUBLIC_KEY=pk_live_... +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Multi-platform: Canada (optional — SaaS only) +STRIPE_CA_PUBLIC_KEY=pk_live_... +STRIPE_CA_SECRET_KEY=sk_live_... +STRIPE_CA_WEBHOOK_SECRET=whsec_... + +# Multi-platform: Ireland (optional — SaaS only) +STRIPE_IE_PUBLIC_KEY=pk_live_... +STRIPE_IE_SECRET_KEY=sk_live_... +STRIPE_IE_WEBHOOK_SECRET=whsec_... + +# Which platform is treated as primary when multiple exist +STRIPE_PRIMARY_PLATFORM=ca # 'ca' | 'ie' | (empty) + +# SaaS-mode settings +APP_SAAS_MODE_ENABLED=false +APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=1.5 +APP_STRIPE_CONNECT_ACCOUNT_TYPE=express # 'express' | 'standard' | 'custom' +``` + +These map to `config/services.php` under the `stripe` key via Laravel's config resolution. + +--- + +## Architecture & Flow + +### Checkout Payment Flow + +``` +Browser Backend (Laravel) Stripe API + | | | + |-- POST /stripe/payment_intent --> | + | CreatePaymentIntentActionPublic | + | | | + | CreatePaymentIntentHandler | + | |--> (fetch order, account) | + | |--> StripeClientFactory | + | | (picks correct platform key)| + | | | + | |--> StripePaymentIntentCreationService + | | paymentIntents.create() --->| + | |<-- { paymentIntentId, secret } --| + | | | + | |--> Save stripe_payment record | + |<-- { client_secret, public_key, account_id } ---------- | + | | | + |-- Stripe.js Elements render (user fills card details) | + |-- stripe.confirmPayment() --------------------------------->| + |<-- redirect to /payment_return ------------------------------| + | | | + |-- GET /stripe/payment_intent --> | + | GetPaymentIntentActionPublic | + | |--> Retrieve PaymentIntent | + | | from Stripe API | + | |--> If succeeded & DB not updated:| + | | PaymentIntentSucceededHandler | + |<-- { status } ---------------------------------------- | + | | | + | (Stripe also sends webhook asynchronously) | + | POST /webhooks/stripe ---------------<---| + | StripeIncomingWebhookAction | + | | | + | IncomingWebhookHandler | + | |--> PaymentIntentSucceededHandler | + | | - update order status | + | | - activate attendees | + | | - update product quantities | + | | - fire OrderStatusChangedEvent| +``` + +--- + +## Database Schema + +### `stripe_customers` + +Caches Stripe customer records to avoid repeated API lookups. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigint PK | Auto-increment | +| `name` | string | Customer name | +| `email` | string | Customer email | +| `stripe_customer_id` | string | Stripe `cus_...` ID | +| `stripe_account_id` | string (nullable) | Connected Stripe account (SaaS mode) | +| `created_at` / `updated_at` / `deleted_at` | timestamps | Standard | + +### `stripe_payments` + +One record per Stripe Payment Intent. Central Stripe payment record linked to an order. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigint PK | | +| `order_id` | bigint FK | Links to `orders.id` | +| `payment_intent_id` | string | Stripe `pi_...` ID | +| `connected_account_id` | string (nullable) | Stripe Connect account ID | +| `charge_id` | string (nullable) | Stripe `ch_...` ID | +| `payment_method_id` | string (nullable) | Stripe `pm_...` ID | +| `amount_received` | integer (nullable) | Amount received in minor units | +| `currency` | string | ISO currency code (uppercase) | +| `last_error` | json (nullable) | Last payment error from Stripe | +| `application_fee_gross` | integer | Application fee (gross) in minor units | +| `application_fee_net` | integer | Application fee (net) in minor units | +| `application_fee_vat` | integer | VAT on application fee in minor units | +| `application_fee_vat_rate` | decimal (nullable) | VAT rate applied | +| `stripe_platform` | string (nullable) | Platform identifier (`ca`, `ie`) | +| `payout_id` | string (nullable) | Associated Stripe payout ID | + +### `stripe_payouts` + +Tracks Stripe payout records for VAT reconciliation in SaaS mode. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigint PK | | +| `payout_id` | string | Stripe `po_...` ID (unique) | +| `stripe_platform` | string | Platform identifier | +| `amount_minor` | integer | Payout amount in minor units | +| `currency` | string | ISO currency code | +| `payout_date` | timestamp | Payout arrival date | +| `payout_status` | string | Stripe payout status | +| `total_application_fee_vat_minor` | integer (nullable) | Aggregated VAT on application fees | +| `total_application_fee_net_minor` | integer (nullable) | Aggregated net application fees | +| `metadata` | json (nullable) | Raw Stripe payout metadata | +| `reconciled` | boolean | Whether VAT reconciliation was completed | + +### `account_stripe_platforms` + +Stores per-platform Stripe Connect account info (SaaS mode only). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigint PK | | +| `account_id` | bigint FK | Hi.Events account | +| `stripe_connect_account_type` | string (nullable) | `express` / `standard` / `custom` | +| `stripe_connect_platform` | string(2) (nullable) | `ca` / `ie` / `NULL` | +| `stripe_account_id` | string (nullable) | Stripe `acct_...` ID | +| `stripe_setup_completed_at` | timestamp (nullable) | When the Connect onboarding was completed | +| `stripe_account_details` | jsonb (nullable) | Cached Stripe account capability details | + +### `orders` table additions + +| Column | Type | Description | +|--------|------|-------------| +| `payment_provider` | string (nullable) | `STRIPE` / `OFFLINE` — set on payment completion | + +### `event_settings` additions + +| Column | Type | Description | +|--------|------|-------------| +| `payment_providers` | json array | Which providers are enabled: `["STRIPE"]`, `["OFFLINE"]`, or both | + +--- + +## Backend — Infrastructure Layer + +### `StripeConfigurationService` + +**File:** `app/Services/Infrastructure/Stripe/StripeConfigurationService.php` + +Provides all Stripe configuration values, supporting per-platform lookups. + +| Method | Description | +|--------|-------------| +| `getSecretKey(?StripePlatform)` | Returns secret key for default, `ca`, or `ie` platform | +| `getPublicKey(?StripePlatform)` | Returns publishable key for default, `ca`, or `ie` platform | +| `getPrimaryPlatform()` | Returns the configured primary platform enum value | +| `getAllWebhookSecrets()` | Returns array of all webhook secrets, sorted with primary platform first | + +### `StripeClientFactory` + +**File:** `app/Services/Infrastructure/Stripe/StripeClientFactory.php` + +Creates `Stripe\StripeClient` instances with the correct API key. + +```php +// Usage: creates a client for the specified platform (or default keys if null) +$client = $stripeClientFactory->createForPlatform($stripePlatform); +``` + +Throws `StripeClientConfigurationException` if the secret key is not configured for the requested platform. + +--- + +## Backend — Domain Layer + +### `StripePaymentIntentCreationService` + +**File:** `app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php` + +Handles the actual Stripe API calls for creating and retrieving Payment Intents. + +Key behaviours: +- **Creates a Stripe Customer** if one does not already exist for the order's email + connected account combination (upsert pattern) +- **Calculates application fees** via `OrderApplicationFeeCalculationService` and includes `application_fee_amount` in the Payment Intent when SaaS mode is active +- **Automatic payment methods enabled** (`automatic_payment_methods.enabled = true`) +- **Stores metadata** on the Payment Intent: `order_id`, `event_id`, `order_short_id`, `account_id`, and application fee breakdown + +### `StripePaymentIntentRefundService` + +**File:** `app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php` + +Issues refunds via `stripe.refunds.create()`. In SaaS mode, routes the refund to the correct connected account. + +### `StripeAccountSyncService` + +**File:** `app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php` + +Synchronises Stripe Connect account status with the local database. + +| Method | Description | +|--------|-------------| +| `syncStripeAccountStatus()` | Called from webhook; updates `stripe_setup_completed_at` based on `charges_enabled && payouts_enabled` | +| `markAccountAsComplete()` | Forces account to complete state and triggers VAT setting creation for EU countries | +| `createStripeAccountSetupUrl()` | Creates a Stripe Account Link for Connect onboarding | +| `isStripeAccountComplete()` | Returns `true` if `charges_enabled && payouts_enabled` | + +### `StripePaymentPlatformFeeExtractionService` + +**File:** `app/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionService.php` + +Extracts fee breakdown (Stripe fees vs application fees) from the balance transaction on a Charge, and stores them in `order_payment_platform_fees`. Also handles currency conversion for EU VAT. + +### `StripePayoutService` + +**File:** `app/Services/Domain/Payment/Stripe/StripePayoutService.php` + +Creates/updates payout records when a Stripe `payout.paid` or `payout.updated` event is received, aggregating VAT amounts across all charges in the payout for reconciliation. + +### `StripeRefundExpiredOrderService` + +**File:** `app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php` + +Handles the scenario where a payment succeeds but the order has already expired (past `reserved_until`). Automatically refunds the customer and sends a notification email. + +### Webhook Event Handlers + +Located in `app/Services/Domain/Payment/Stripe/EventHandlers/`. + +| Handler | Stripe Event(s) | Description | +|---------|-----------------|-------------| +| `PaymentIntentSucceededHandler` | `payment_intent.succeeded` | Marks order as `COMPLETED` / `PAYMENT_RECEIVED`, activates attendees, updates product quantities, fires domain events, stores application fee | +| `PaymentIntentFailedHandler` | `payment_intent.payment_failed` | Updates order payment status to `PAYMENT_FAILED` | +| `ChargeSucceededHandler` | `charge.succeeded`, `charge.updated` | Extracts and stores platform fee breakdown from balance transaction | +| `ChargeRefundUpdatedHandler` | `refund.updated`, `refund.created`, `charge.refunded` | Processes refund success/failure, updates order refund status and statistics | +| `AccountUpdateHandler` | `account.updated` | Syncs Stripe Connect account status to local DB | +| `PayoutPaidHandler` | `payout.paid`, `payout.updated` | Creates/updates payout reconciliation records | + +--- + +## Backend — Application Layer (Handlers) + +### `CreatePaymentIntentHandler` + +**File:** `app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php` + +Orchestrates the full payment intent creation flow: + +1. Validates order session and status (`RESERVED`, not expired) +2. Loads account with `AccountStripePlatformDomainObject` and `AccountConfigurationDomainObject` +3. Resolves the correct `StripePlatform` (from account's active platform or system default) +4. If order already has a `stripe_payment` record, retrieves the existing `client_secret` instead of creating a new Payment Intent +5. Creates a new Payment Intent via `StripePaymentIntentCreationService` +6. Persists the `stripe_payments` record +7. Returns `CreatePaymentIntentResponseDTO` with `client_secret`, `public_key`, `account_id`, `stripe_platform` + +### `GetPaymentIntentHandler` + +**File:** `app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php` + +Called from the payment return page. Retrieves the Payment Intent from Stripe and, as a safety net, manually triggers `PaymentIntentSucceededHandler` if the payment succeeded but the order DB record hasn't been updated yet (webhook may be delayed or failed). + +### `IncomingWebhookHandler` + +**File:** `app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php` + +- Validates webhook signature against all configured platform webhook secrets (tries each in order) +- Deduplicates events via Redis/cache (`stripe_event_{id}`, TTL 60 minutes) +- Routes events to the appropriate domain event handler via `switch` + +**Handled Events:** +``` +payment_intent.succeeded +payment_intent.payment_failed +account.updated +refund.updated / refund.created +charge.refunded +charge.succeeded / charge.updated +payout.paid / payout.updated +``` + +### `CreateStripeConnectAccountHandler` + +**File:** `app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php` + +SaaS-mode only. Creates or retrieves a Stripe Connect account for an organiser, stores it in `account_stripe_platforms`, and generates the Account Link URL for onboarding. + +### `RefundOrderHandler` + +**File:** `app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php` + +Admin-initiated refund. Uses the payment's original `stripe_platform` to select the correct Stripe client, issues a refund, and marks the order as `REFUND_PENDING`. + +--- + +## Backend — HTTP Layer (Actions & Routes) + +### Routes + +Defined in `routes/api.php`. + +#### Public Routes (`/api/public/`) + +| Method | Path | Handler Action | Description | +|--------|------|----------------|-------------| +| `POST` | `/events/{event_id}/order/{order_short_id}/stripe/payment_intent` | `CreatePaymentIntentActionPublic` | Create or retrieve a Stripe Payment Intent | +| `GET` | `/events/{event_id}/order/{order_short_id}/stripe/payment_intent` | `GetPaymentIntentActionPublic` | Retrieve Payment Intent status (called on payment return) | +| `POST` | `/webhooks/stripe` | `StripeIncomingWebhookAction` | Stripe webhook endpoint | + +#### Authenticated Routes (`/api/` + `auth:api` middleware) + +| Method | Path | Handler Action | Description | +|--------|------|----------------|-------------| +| `GET` | `/accounts/{account_id}/stripe/connect_accounts` | `GetStripeConnectAccountsAction` | List organiser's Stripe Connect accounts | +| `POST` | `/accounts/{account_id}/stripe/connect` | `CreateStripeConnectAccountAction` | Initiate Stripe Connect onboarding | +| `POST` | `/events/{event_id}/orders/{order_id}/refund` | `RefundOrderAction` | Issue a refund via Stripe | + +### Action Classes + +#### `CreatePaymentIntentActionPublic` + +**File:** `app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php` + +Returns: +```json +{ + "client_secret": "pi_xxx_secret_xxx", + "account_id": "acct_xxx", + "public_key": "pk_live_xxx", + "stripe_platform": "ca" +} +``` + +#### `StripeIncomingWebhookAction` + +**File:** `app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php` + +Dispatches webhook processing as a queued closure (fire-and-forget) and immediately returns `HTTP 204 No Content`. This prevents Stripe from retrying due to slow processing. + +--- + +## Backend — Webhook Handling + +### Webhook Security + +The `IncomingWebhookHandler` calls `Stripe\Webhook::constructEvent()` with the raw request body and `Stripe-Signature` header. It iterates through all configured platform webhook secrets and uses the first one that validates successfully. + +### Idempotency + +All webhook events are cached with key `stripe_event_{event_id}` (TTL: 60 minutes). Payment intent success is additionally cached with `payment_intent_handled_{pi_id}` to prevent double-processing. + +### Webhook Registration + +Register your webhook endpoint in the Stripe Dashboard: +- **URL:** `https://your-domain.com/api/public/webhooks/stripe` +- **Events to listen for:** + - `payment_intent.succeeded` + - `payment_intent.payment_failed` + - `charge.succeeded` + - `charge.updated` + - `charge.refunded` + - `refund.created` + - `refund.updated` + - `account.updated` _(SaaS mode)_ + - `payout.paid` _(SaaS mode)_ + - `payout.updated` _(SaaS mode)_ + +--- + +## Frontend — React Components & Queries + +### Component Hierarchy + +``` +Payment/index.tsx — main payment page +├── StripePaymentMethod/index.tsx — Stripe tab/section +│ ├── useCreateStripePaymentIntent — fetches client_secret & public_key +│ └── Elements (stripe-js) — wraps Stripe context +│ └── StripeCheckoutForm/index.tsx — PaymentElement + submit logic +└── OfflinePaymentMethod/index.tsx — offline instructions +``` + +### `Payment/index.tsx` + +**File:** `frontend/src/components/routes/product-widget/Payment/index.tsx` + +- Reads `event.settings.payment_providers` to determine which methods are available +- Defaults to Stripe if enabled, otherwise offline +- Shows method selector tabs when both are available +- Handles submit orchestration: calls Stripe's `handleSubmit` or the offline mutation + +Key state: +- `activePaymentMethod: 'STRIPE' | 'OFFLINE' | null` +- `submitHandler`: a ref to the Stripe form's submit function, passed down via `setSubmitHandler` + +### `StripePaymentMethod/index.tsx` + +**File:** `frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx` + +1. Calls `useCreateStripePaymentIntent` (which POSTs to the backend) +2. Calls `loadStripe(publicKey, { stripeAccount })` once the client secret is available +3. Renders Stripe's `` provider with the client secret and theme settings +4. Adapts the Stripe theme to the event's dark/light mode via `validateThemeSettings` + +### `StripeCheckoutForm/index.tsx` + +**File:** `frontend/src/components/forms/StripeCheckoutForm/index.tsx` + +- Uses `useStripe()` and `useElements()` hooks +- Renders Stripe's `` with accordion layout +- On submit: calls `stripe.confirmPayment()` with a redirect `return_url` pointing to `/checkout/{eventId}/{orderShortId}/payment_return` +- Surfaces card/validation errors in an `` +- Guards against double-payment by checking `order.payment_status` + +### `useCreateStripePaymentIntent` + +**File:** `frontend/src/queries/useCreateStripePaymentIntent.ts` + +```typescript +// TanStack Query — fires on mount, no caching (staleTime=0, gcTime=0) +useCreateStripePaymentIntent(eventId, orderShortId) +// Returns: { client_secret, account_id, public_key, stripe_platform } +``` + +### `useGetOrderStripePaymentIntentPublic` + +**File:** `frontend/src/queries/useGetOrderStripePaymentIntentPublic.ts` + +Called from the `PaymentReturn` page to poll the payment intent status after Stripe redirects back. + +--- + +## SaaS Mode vs. Open-Source Mode + +### Open-Source Mode (`APP_SAAS_MODE_ENABLED=false`) + +- Uses `STRIPE_PUBLIC_KEY` / `STRIPE_SECRET_KEY` directly +- **No** `stripe_account` option passed to Payment Intent creation +- **No** `application_fee_amount` charged +- Stripe Connect is **not** available (throws `SaasModeEnabledException`) +- Refunds work without a connected account ID + +### SaaS Mode (`APP_SAAS_MODE_ENABLED=true`) + +- Organizers connect their own Stripe accounts via Stripe Connect Express/Standard +- Payment Intents are created on behalf of the connected account (`stripe_account` option) +- Application fee (`APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT`) is collected by the platform +- Connect account status (capabilities, requirements) is synced via `account.updated` webhooks +- VAT is calculated on application fees for EU organizers + +--- + +## Multi-Platform Support + +Hi.Events cloud uses separate Stripe accounts for different regions, controlled by `StripePlatform` enum. + +**File:** `app/DomainObjects/Enums/StripePlatform.php` + +```php +enum StripePlatform: string +{ + case CANADA = 'ca'; + case IRELAND = 'ie'; +} +``` + +The `account_stripe_platforms` table stores which platform each organizer's Connect account belongs to. `StripeConfigurationService` selects the correct API keys based on platform, falling back to defaults. + +--- + +## Refund Flow + +``` +Admin → POST /events/{id}/orders/{id}/refund + RefundOrderAction + ↓ + RefundOrderHandler + ├── Validate order has stripe_payment + ├── Get stripe_platform from stripe_payments record + ├── StripeClientFactory.createForPlatform(platform) + ├── StripePaymentIntentRefundService.refundPayment() + │ └── stripe.refunds.create({ payment_intent, amount }) + ├── Mark order refund_status = REFUND_PENDING + └── (optionally) send OrderRefunded email + ↓ + [Stripe webhook: refund.updated / refund.created] + ↓ + ChargeRefundUpdatedHandler + ├── Update order total_refunded + ├── Set refund_status → REFUNDED | PARTIALLY_REFUNDED | REFUND_FAILED + ├── Update event statistics + └── Create order_refunds record +``` + +--- + +## Payout Reconciliation + +> Relevant only in SaaS mode with EU VAT handling enabled. + +When Stripe sends a `payout.paid` or `payout.updated` event: + +1. `PayoutPaidHandler` is called with the payout object +2. `StripePayoutService.createOrUpdatePayout()` aggregates all charges in the payout +3. For each charge, VAT and net amounts (already converted to settlement currency) are summed +4. Results are stored in `stripe_payouts` for accounting/reporting + +--- + +## Testing + +Unit tests are located in `tests/Unit/Services/`. + +| Test File | Coverage | +|-----------|----------| +| `Infrastructure/Stripe/StripeConfigurationServiceTest.php` | Key resolution per platform | +| `Infrastructure/Stripe/StripeClientFactoryTest.php` | Client creation, missing key exception | +| `Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionServiceTest.php` | Fee extraction logic | +| `Domain/Payment/Stripe/StripeAccountSyncServiceTest.php` | Account sync logic | +| `Domain/Payment/Stripe/EventHandlers/PayoutPaidHandlerTest.php` | Payout event handling | +| `Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php` | Connect flow | +| `Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php` | Account listing | +| `DomainObjects/Enums/StripePlatformTest.php` | Enum parsing | +| `Domain/Order/OrderPlatformFeePassThroughServiceTest.php` | Fee pass-through | + +Run backend tests: +```bash +cd docker/development +docker compose -f docker-compose.dev.yml exec backend php artisan test --filter=Stripe +``` + +--- + +## Key Files Reference + +| File | Description | +|------|-------------| +| `app/Services/Infrastructure/Stripe/StripeClientFactory.php` | Creates `StripeClient` with correct platform keys | +| `app/Services/Infrastructure/Stripe/StripeConfigurationService.php` | Reads Stripe config values per platform | +| `app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php` | Creates/retrieves Payment Intents | +| `app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php` | Issues refunds | +| `app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php` | Stripe Connect account sync | +| `app/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionService.php` | Extracts Stripe and application fees | +| `app/Services/Domain/Payment/Stripe/StripePayoutService.php` | Payout VAT reconciliation | +| `app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php` | Auto-refund for expired orders | +| `app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php` | Orchestrates Payment Intent creation | +| `app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php` | Retrieves intent + safety-net completion | +| `app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php` | Routes Stripe webhook events | +| `app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php` | Admin refund orchestration | +| `app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php` | Stripe Connect onboarding | +| `app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php` | Fulfils order on payment success | +| `app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php` | Handles refund webhook events | +| `app/Services/Domain/Payment/Stripe/EventHandlers/ChargeSucceededHandler.php` | Extracts platform fees from charges | +| `app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php` | HTTP action for Stripe webhooks | +| `app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php` | HTTP action: create payment intent | +| `app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php` | HTTP action: get payment intent | +| `frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx` | Stripe payment tab (React) | +| `frontend/src/components/forms/StripeCheckoutForm/index.tsx` | Stripe PaymentElement form (React) | +| `frontend/src/queries/useCreateStripePaymentIntent.ts` | TanStack Query hook for payment intent | +| `app/DomainObjects/Enums/PaymentProviders.php` | `STRIPE` / `OFFLINE` enum | +| `app/DomainObjects/Enums/StripePlatform.php` | `ca` / `ie` platform enum | From b08ff54307a8db395f495b3cb7238eccd612783c Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 15:46:40 +0530 Subject: [PATCH 2/8] Add Razorpay payment integration Introduce full Razorpay payment provider support: add env keys, docs and docker updates, and a DB migration for razorpay_orders. New domain objects, Eloquent model, repository + interface, and generated RazorpayOrder domain class. Implement infrastructure services (client factory, configuration, signature verification), domain services (order creation, payment completion, refund), application handlers (create order, payment callback, incoming webhook, refund handler) and webhook/event handlers for payment.captured, payment.failed and refund.processed with idempotency via cache. Add HTTP actions (public create order, payment callback, webhook), integrate provider in RepositoryServiceProvider, and add a Razorpay-specific exception. Update refund flow to route between Stripe and Razorpay based on order payment provider and surface gateway errors generically. Frontend: add Razorpay payment method component, client call and a hook to create Razorpay orders. Composer and service config updated accordingly. --- backend/.env.example | 4 + .../DomainObjects/Enums/PaymentProviders.php | 1 + .../RazorpayOrderDomainObjectAbstract.php | 174 ++++++++++++++++++ .../RazorpayOrderDomainObject.php | 12 ++ .../RazorpayClientConfigurationException.php | 9 + .../RazorpayIncomingWebhookAction.php | 37 ++++ .../CreateRazorpayOrderActionPublic.php | 22 +++ .../RazorpayPaymentCallbackActionPublic.php | 33 ++++ .../Orders/Payment/RefundOrderAction.php | 33 ++-- backend/app/Models/RazorpayOrder.php | 21 +++ .../Providers/RepositoryServiceProvider.php | 5 +- .../Eloquent/RazorpayOrdersRepository.php | 23 +++ .../RazorpayOrdersRepositoryInterface.php | 12 ++ .../Razorpay/CreateRazorpayOrderHandler.php | 65 +++++++ .../DTO/CreateRazorpayOrderResponseDTO.php | 17 ++ .../Razorpay/HandlePaymentCallbackHandler.php | 45 +++++ .../Razorpay/IncomingWebhookHandler.php | 77 ++++++++ .../Payment/Razorpay/RefundOrderHandler.php | 57 ++++++ .../EventHandlers/PaymentCapturedHandler.php | 59 ++++++ .../EventHandlers/PaymentFailedHandler.php | 78 ++++++++ .../EventHandlers/RefundProcessedHandler.php | 94 ++++++++++ .../Razorpay/RazorpayOrderCreationService.php | 65 +++++++ .../RazorpayPaymentCompletionService.php | 160 ++++++++++++++++ .../Razorpay/RazorpayRefundService.php | 61 ++++++ .../RazorpaySignatureVerificationService.php | 72 ++++++++ .../Razorpay/RazorpayClientFactory.php | 31 ++++ .../Razorpay/RazorpayConfigurationService.php | 33 ++++ backend/composer.json | 1 + backend/config/services.php | 7 + ...03_000001_create_razorpay_orders_table.php | 36 ++++ backend/docs/razorpay-deployment.md | 94 ++++++++++ docker/all-in-one/.env.example | 6 +- docker/all-in-one/docker-compose.yml | 3 + docker/backend/docker-compose.yml | 3 + docker/development/.env | 3 + frontend/src/api/order.client.ts | 19 ++ .../Payment/PaymentMethods/Razorpay/index.tsx | 164 +++++++++++++++++ .../routes/product-widget/Payment/index.tsx | 66 ++++--- .../src/queries/useCreateRazorpayOrder.ts | 17 ++ 39 files changed, 1685 insertions(+), 34 deletions(-) create mode 100644 backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php create mode 100644 backend/app/Exceptions/Razorpay/RazorpayClientConfigurationException.php create mode 100644 backend/app/Http/Actions/Common/Webhooks/RazorpayIncomingWebhookAction.php create mode 100644 backend/app/Http/Actions/Orders/Payment/Razorpay/CreateRazorpayOrderActionPublic.php create mode 100644 backend/app/Http/Actions/Orders/Payment/Razorpay/RazorpayPaymentCallbackActionPublic.php create mode 100644 backend/app/Models/RazorpayOrder.php create mode 100644 backend/app/Repository/Eloquent/RazorpayOrdersRepository.php create mode 100644 backend/app/Repository/Interfaces/RazorpayOrdersRepositoryInterface.php create mode 100644 backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php create mode 100644 backend/app/Services/Application/Handlers/Order/Payment/Razorpay/DTO/CreateRazorpayOrderResponseDTO.php create mode 100644 backend/app/Services/Application/Handlers/Order/Payment/Razorpay/HandlePaymentCallbackHandler.php create mode 100644 backend/app/Services/Application/Handlers/Order/Payment/Razorpay/IncomingWebhookHandler.php create mode 100644 backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RefundOrderHandler.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentCapturedHandler.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentFailedHandler.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RefundProcessedHandler.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/RazorpayOrderCreationService.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentCompletionService.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/RazorpayRefundService.php create mode 100644 backend/app/Services/Domain/Payment/Razorpay/RazorpaySignatureVerificationService.php create mode 100644 backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php create mode 100644 backend/app/Services/Infrastructure/Razorpay/RazorpayConfigurationService.php create mode 100644 backend/database/migrations/2026_07_03_000001_create_razorpay_orders_table.php create mode 100644 backend/docs/razorpay-deployment.md create mode 100644 frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx create mode 100644 frontend/src/queries/useCreateRazorpayOrder.ts diff --git a/backend/.env.example b/backend/.env.example index c5ee655b31..fcb4fb086f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,6 +19,10 @@ STRIPE_PUBLIC_KEY= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +RAZORPAY_KEY_ID= +RAZORPAY_KEY_SECRET= +RAZORPAY_WEBHOOK_SECRET= + CORS_ALLOWED_ORIGINS=* LOG_CHANNEL=stderr diff --git a/backend/app/DomainObjects/Enums/PaymentProviders.php b/backend/app/DomainObjects/Enums/PaymentProviders.php index 8eb53645c9..25d80c1141 100644 --- a/backend/app/DomainObjects/Enums/PaymentProviders.php +++ b/backend/app/DomainObjects/Enums/PaymentProviders.php @@ -8,4 +8,5 @@ enum PaymentProviders: string case STRIPE = 'STRIPE'; case OFFLINE = 'OFFLINE'; + case RAZORPAY = 'RAZORPAY'; } diff --git a/backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php new file mode 100644 index 0000000000..188d3a5e3d --- /dev/null +++ b/backend/app/DomainObjects/Generated/RazorpayOrderDomainObjectAbstract.php @@ -0,0 +1,174 @@ + $this->id ?? null, + 'order_id' => $this->order_id ?? null, + 'razorpay_order_id' => $this->razorpay_order_id ?? null, + 'razorpay_payment_id' => $this->razorpay_payment_id ?? null, + 'razorpay_signature' => $this->razorpay_signature ?? null, + 'amount_minor' => $this->amount_minor ?? null, + 'currency' => $this->currency ?? null, + 'status' => $this->status ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_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 setAmountMinor(int $amount_minor): self + { + $this->amount_minor = $amount_minor; + return $this; + } + + public function getAmountMinor(): int + { + return $this->amount_minor; + } + + public function setCurrency(string $currency): self + { + $this->currency = $currency; + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/RazorpayOrderDomainObject.php b/backend/app/DomainObjects/RazorpayOrderDomainObject.php index 5d2df336d7..45c154a823 100644 --- a/backend/app/DomainObjects/RazorpayOrderDomainObject.php +++ b/backend/app/DomainObjects/RazorpayOrderDomainObject.php @@ -4,4 +4,16 @@ class RazorpayOrderDomainObject extends Generated\RazorpayOrderDomainObjectAbstract { + private ?OrderDomainObject $order = null; + + public function getOrder(): ?OrderDomainObject + { + return $this->order; + } + + public function setOrder(?OrderDomainObject $order): self + { + $this->order = $order; + return $this; + } } diff --git a/backend/app/Exceptions/Razorpay/RazorpayClientConfigurationException.php b/backend/app/Exceptions/Razorpay/RazorpayClientConfigurationException.php new file mode 100644 index 0000000000..e715d52540 --- /dev/null +++ b/backend/app/Exceptions/Razorpay/RazorpayClientConfigurationException.php @@ -0,0 +1,9 @@ +header('x-razorpay-signature'); + + if (!$signature) { + return response()->noContent(Response::HTTP_BAD_REQUEST); + } + + // We dispatch the handler asynchronously to ensure the webhook returns a 200/204 + // to Razorpay immediately, avoiding timeout retries. + dispatch(function () use ($request, $signature) { + $this->incomingWebhookHandler->handle( + rawBody: $request->getContent(), + signature: $signature, + payload: $request->all(), + ); + }); + + return response()->noContent(); + } +} 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 0000000000..94af0e6383 --- /dev/null +++ b/backend/app/Http/Actions/Orders/Payment/Razorpay/CreateRazorpayOrderActionPublic.php @@ -0,0 +1,22 @@ +createRazorpayOrderHandler->handle($orderShortId); + + return $this->jsonResponse($response->toArray()); + } +} diff --git a/backend/app/Http/Actions/Orders/Payment/Razorpay/RazorpayPaymentCallbackActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Razorpay/RazorpayPaymentCallbackActionPublic.php new file mode 100644 index 0000000000..04da96b9d8 --- /dev/null +++ b/backend/app/Http/Actions/Orders/Payment/Razorpay/RazorpayPaymentCallbackActionPublic.php @@ -0,0 +1,33 @@ +validate([ + 'razorpay_order_id' => 'required|string', + 'razorpay_payment_id' => 'required|string', + 'razorpay_signature' => 'required|string', + ]); + + $this->handlePaymentCallbackHandler->handle( + razorpayOrderId: $request->input('razorpay_order_id'), + razorpayPaymentId: $request->input('razorpay_payment_id'), + razorpaySignature: $request->input('razorpay_signature'), + ); + + return $this->jsonResponse(['status' => 'success']); + } +} diff --git a/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php index 205409e2ed..8c6c6f137e 100644 --- a/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php +++ b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php @@ -8,7 +8,9 @@ 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\Razorpay\RefundOrderHandler as RazorpayRefundOrderHandler; +use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\RefundOrderHandler as StripeRefundOrderHandler; +use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Stripe\Exception\ApiErrorException; @@ -16,8 +18,11 @@ class RefundOrderAction extends BaseAction { - public function __construct(private readonly RefundOrderHandler $refundOrderHandler) - { + public function __construct( + private readonly StripeRefundOrderHandler $stripeRefundOrderHandler, + private readonly RazorpayRefundOrderHandler $razorpayRefundOrderHandler, + private readonly OrderRepositoryInterface $orderRepository + ) { } /** @@ -29,16 +34,22 @@ public function __invoke(RefundOrderRequest $request, int $eventId, int $orderId $this->isActionAuthorized($eventId, EventDomainObject::class); try { - $order = $this->refundOrderHandler->handle( - refundOrderDTO: RefundOrderDTO::fromArray(array_merge($request->validated(), [ - 'event_id' => $eventId, - 'order_id' => $orderId, - ])) - ); - } catch (ApiErrorException|RefundNotPossibleException $exception) { + $order = $this->orderRepository->findById($orderId); + + $refundOrderDTO = RefundOrderDTO::fromArray(array_merge($request->validated(), [ + 'event_id' => $eventId, + 'order_id' => $orderId, + ])); + + if ($order->getPaymentProvider() === \HiEvents\DomainObjects\Enums\PaymentProviders::RAZORPAY->name) { + $order = $this->razorpayRefundOrderHandler->handle($refundOrderDTO); + } else { + $order = $this->stripeRefundOrderHandler->handle($refundOrderDTO); + } + } catch (ApiErrorException|RefundNotPossibleException|\HiEvents\Exceptions\Razorpay\RazorpayClientConfigurationException $exception) { throw ValidationException::withMessages([ 'amount' => $exception instanceof ApiErrorException - ? 'Stripe error: ' . $exception->getMessage() + ? 'Payment gateway error: ' . $exception->getMessage() : $exception->getMessage(), ]); } diff --git a/backend/app/Models/RazorpayOrder.php b/backend/app/Models/RazorpayOrder.php new file mode 100644 index 0000000000..34f6e41d8e --- /dev/null +++ b/backend/app/Models/RazorpayOrder.php @@ -0,0 +1,21 @@ +belongsTo(Order::class); + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 55f77ef5b6..fe648a1eed 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -42,6 +42,7 @@ use HiEvents\Repository\Eloquent\QuestionAndAnswerViewRepository; use HiEvents\Repository\Eloquent\QuestionAnswerRepository; use HiEvents\Repository\Eloquent\QuestionRepository; +use HiEvents\Repository\Eloquent\RazorpayOrdersRepository; use HiEvents\Repository\Eloquent\StripeCustomerRepository; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\StripePayoutsRepository; @@ -89,6 +90,7 @@ use HiEvents\Repository\Interfaces\QuestionAndAnswerViewRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use HiEvents\Repository\Interfaces\RazorpayOrdersRepositoryInterface; use HiEvents\Repository\Interfaces\StripeCustomerRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Repository\Interfaces\StripePayoutsRepositoryInterface; @@ -152,7 +154,8 @@ class RepositoryServiceProvider extends ServiceProvider AccountVatSettingRepositoryInterface::class => AccountVatSettingRepository::class, TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, - WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, + WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, + RazorpayOrdersRepositoryInterface::class => RazorpayOrdersRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/RazorpayOrdersRepository.php b/backend/app/Repository/Eloquent/RazorpayOrdersRepository.php new file mode 100644 index 0000000000..4feb6c88a2 --- /dev/null +++ b/backend/app/Repository/Eloquent/RazorpayOrdersRepository.php @@ -0,0 +1,23 @@ + + */ +class RazorpayOrdersRepository extends BaseRepository implements RazorpayOrdersRepositoryInterface +{ + protected function getModel(): string + { + return RazorpayOrder::class; + } + + public function getDomainObject(): string + { + return RazorpayOrderDomainObject::class; + } +} diff --git a/backend/app/Repository/Interfaces/RazorpayOrdersRepositoryInterface.php b/backend/app/Repository/Interfaces/RazorpayOrdersRepositoryInterface.php new file mode 100644 index 0000000000..bfbe9f02c3 --- /dev/null +++ b/backend/app/Repository/Interfaces/RazorpayOrdersRepositoryInterface.php @@ -0,0 +1,12 @@ + + */ +interface RazorpayOrdersRepositoryInterface extends RepositoryInterface +{ +} diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php new file mode 100644 index 0000000000..111dbe6ab6 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php @@ -0,0 +1,65 @@ +orderRepository->findByShortId($orderShortId); + + if (!$order || !$this->checkoutSessionManagementService->verifySession($order->getSessionIdentifier())) { + throw new ResourceNotFoundException('Order not found or invalid session'); + } + + // Check if a Razorpay order already exists for this order + $existingRazorpayOrder = $this->razorpayOrdersRepository->findFirstWhere([ + RazorpayOrderDomainObjectAbstract::ORDER_ID => $order->getId(), + ]); + + if ($existingRazorpayOrder) { + $razorpayOrderId = $existingRazorpayOrder->getRazorpayOrderId(); + $amountMinor = $existingRazorpayOrder->getAmountMinor(); + $currency = $existingRazorpayOrder->getCurrency(); + } else { + // Create a new one + $result = $this->razorpayOrderCreationService->createOrder($order); + $razorpayOrderId = $result['razorpay_order_id']; + $amountMinor = $result['amount_minor']; + $currency = $result['currency']; + } + + return new CreateRazorpayOrderResponseDTO( + razorpay_order_id: $razorpayOrderId, + key_id: $this->razorpayConfigurationService->getKeyId() ?? '', + amount_minor: $amountMinor, + currency: $currency, + prefill: [ + 'name' => $order->getFirstName() . ' ' . $order->getLastName(), + 'email' => $order->getEmail(), + ], + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/DTO/CreateRazorpayOrderResponseDTO.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/DTO/CreateRazorpayOrderResponseDTO.php new file mode 100644 index 0000000000..7d57c7fe96 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/DTO/CreateRazorpayOrderResponseDTO.php @@ -0,0 +1,17 @@ +signatureVerificationService->verifyPaymentSignature($razorpayOrderId, $razorpayPaymentId, $razorpaySignature)) { + throw new ValidationException('Invalid Razorpay signature.'); + } + + $razorpayOrder = $this->razorpayOrdersRepository->findFirstWhere([ + RazorpayOrderDomainObjectAbstract::RAZORPAY_ORDER_ID => $razorpayOrderId, + ]); + + if (!$razorpayOrder) { + throw new ResourceNotFoundException('Razorpay order not found.'); + } + + $this->paymentCompletionService->completePayment( + razorpayOrder: $razorpayOrder, + razorpayPaymentId: $razorpayPaymentId, + razorpaySignature: $razorpaySignature, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/IncomingWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/IncomingWebhookHandler.php new file mode 100644 index 0000000000..da77b2927b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/IncomingWebhookHandler.php @@ -0,0 +1,77 @@ +signatureVerificationService->verifyWebhookSignature($rawBody, $signature)) { + throw new UnauthorizedException('Invalid Razorpay webhook signature'); + } + + $eventId = $payload['event'] ?? null; + if (!$eventId) { + $this->logger->warning('Razorpay webhook missing event type', ['payload' => $payload]); + return; + } + + // Razorpay sends `x-razorpay-event-id` in headers usually, but for idempotency, + // we can use a combination of the event ID or generate a hash of the payload if needed. + // Assuming we rely on the webhook header ID from the HTTP Action later, but here + // we'll extract standard fields. + $webhookId = $payload['event_id'] ?? md5($rawBody); + + $cacheKey = 'razorpay_webhook_handled_' . $webhookId; + + if ($this->cache->has($cacheKey)) { + $this->logger->info('Razorpay webhook already handled', ['webhook_id' => $webhookId]); + return; + } + + $this->logger->info('Processing Razorpay webhook', [ + 'event' => $eventId, + 'webhook_id' => $webhookId, + ]); + + switch ($eventId) { + case 'payment.captured': + $this->paymentCapturedHandler->handle($payload); + break; + case 'payment.failed': + $this->paymentFailedHandler->handle($payload); + break; + case 'refund.processed': + $this->refundProcessedHandler->handle($payload); + break; + default: + $this->logger->debug('Unhandled Razorpay webhook event', ['event' => $eventId]); + } + + $this->cache->put($cacheKey, true, 3600); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RefundOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RefundOrderHandler.php new file mode 100644 index 0000000000..3be9bcf60a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RefundOrderHandler.php @@ -0,0 +1,57 @@ +orderRepository->findById($refundOrderDTO->order_id); + + $this->validateRefund($order, $refundOrderDTO); + + $razorpayOrder = $this->razorpayOrdersRepository->findFirstWhere([ + RazorpayOrderDomainObjectAbstract::ORDER_ID => $order->getId(), + ]); + + if (!$razorpayOrder) { + throw new RefundNotPossibleException(__('Refund not possible: no Razorpay order found.')); + } + + $this->razorpayRefundService->refundPayment( + amount: new MoneyValue($refundOrderDTO->amount, $order->getCurrency()), + razorpayOrder: $razorpayOrder, + ); + + if ($refundOrderDTO->cancel_order) { + $this->cancelOrderService->cancelOrder($order->getId(), false, $refundOrderDTO->notify_buyer); + } + + return $order; + } +} diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentCapturedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentCapturedHandler.php new file mode 100644 index 0000000000..9c94d4543c --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentCapturedHandler.php @@ -0,0 +1,59 @@ +logger->error('Razorpay payment.captured webhook missing order_id or payment id', [ + 'payload' => $payload, + ]); + return; + } + + /** @var RazorpayOrderDomainObject|null $razorpayOrder */ + $razorpayOrder = $this->razorpayOrdersRepository->findFirstWhere([ + RazorpayOrderDomainObjectAbstract::RAZORPAY_ORDER_ID => $razorpayOrderId, + ]); + + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for payment.captured event', [ + 'razorpay_order_id' => $razorpayOrderId, + 'razorpay_payment_id' => $razorpayPaymentId, + ]); + return; + } + + $this->paymentCompletionService->completePayment( + razorpayOrder: $razorpayOrder, + razorpayPaymentId: $razorpayPaymentId, + razorpaySignature: '', // Webhook doesn't include the callback signature + ); + } +} diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentFailedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentFailedHandler.php new file mode 100644 index 0000000000..2b4ac2b8a4 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/PaymentFailedHandler.php @@ -0,0 +1,78 @@ +logger->error('Razorpay payment.failed webhook missing order_id', [ + 'payload' => $payload, + ]); + return; + } + + $this->databaseManager->transaction(function () use ($razorpayOrderId) { + $razorpayOrder = $this->razorpayOrdersRepository->findFirstWhere([ + RazorpayOrderDomainObjectAbstract::RAZORPAY_ORDER_ID => $razorpayOrderId, + ]); + + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for payment.failed event', [ + 'razorpay_order_id' => $razorpayOrderId, + ]); + return; + } + + // Mark the razorpay_order as failed + $this->razorpayOrdersRepository->updateWhere( + attributes: [RazorpayOrderDomainObjectAbstract::STATUS => 'failed'], + where: [RazorpayOrderDomainObjectAbstract::ID => $razorpayOrder->getId()], + ); + + // Update the main order to PAYMENT_FAILED + $updatedOrder = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->updateFromArray($razorpayOrder->getOrderId(), [ + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_FAILED->name, + ]); + + event(new OrderStatusChangedEvent($updatedOrder)); + + $this->logger->info('Razorpay payment failed processed', [ + 'order_id' => $updatedOrder->getId(), + 'razorpay_order_id' => $razorpayOrderId, + ]); + }); + } +} diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RefundProcessedHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RefundProcessedHandler.php new file mode 100644 index 0000000000..b4f800eeff --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RefundProcessedHandler.php @@ -0,0 +1,94 @@ +logger->error('Razorpay refund.processed webhook missing payment_id', [ + 'payload' => $payload, + ]); + return; + } + + $this->databaseManager->transaction(function () use ($refundEntity, $razorpayPaymentId) { + $razorpayOrder = $this->razorpayOrdersRepository->findFirstWhere([ + RazorpayOrderDomainObjectAbstract::RAZORPAY_PAYMENT_ID => $razorpayPaymentId, + ]); + + if (!$razorpayOrder) { + $this->logger->warning('Razorpay order not found for refund.processed event', [ + 'razorpay_payment_id' => $razorpayPaymentId, + ]); + return; + } + + $order = $this->orderRepository->findById($razorpayOrder->getOrderId()); + if (!$order) { + return; + } + + // Create refund record + $amountRefundedMinor = $refundEntity['amount'] ?? 0; + $amountRefundedFloat = $amountRefundedMinor / 100; + + $this->orderRefundRepository->create([ + 'order_id' => $order->getId(), + 'amount_refunded' => $amountRefundedFloat, + 'gateway_transaction_id' => $refundEntity['id'] ?? null, + ]); + + // Determine if full or partial refund + $newTotalRefunded = $order->getTotalRefunded() + $amountRefundedFloat; + $refundStatus = $newTotalRefunded >= $order->getTotalGross() + ? OrderPaymentStatus::REFUNDED->name + : OrderPaymentStatus::PARTIALLY_REFUNDED->name; + + // Update main order + $updatedOrder = $this->orderRepository->updateFromArray($order->getId(), [ + OrderDomainObjectAbstract::PAYMENT_STATUS => $refundStatus, + OrderDomainObjectAbstract::TOTAL_REFUNDED => $newTotalRefunded, + ]); + + event(new OrderStatusChangedEvent($updatedOrder)); + + $this->logger->info('Razorpay refund processed', [ + 'order_id' => $updatedOrder->getId(), + 'razorpay_payment_id' => $razorpayPaymentId, + 'refund_status' => $refundStatus, + ]); + }); + } +} 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 0000000000..c1d31d544f --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpayOrderCreationService.php @@ -0,0 +1,65 @@ +razorpayClientFactory->create(); + $amountMinor = (int) round($order->getTotalGross() * 100); // convert to minor units (paise for INR) + $currency = strtoupper($order->getCurrency()); + + $this->logger->info('Creating Razorpay order', [ + 'order_id' => $order->getId(), + 'amount_minor' => $amountMinor, + 'currency' => $currency, + ]); + + $razorpayOrder = $api->order->create([ + 'amount' => $amountMinor, + 'currency' => $currency, + 'receipt' => (string) $order->getShortId(), + 'payment_capture' => 1, // Auto-capture on payment + ]); + + $this->razorpayOrdersRepository->create([ + RazorpayOrderDomainObjectAbstract::ORDER_ID => $order->getId(), + RazorpayOrderDomainObjectAbstract::RAZORPAY_ORDER_ID => $razorpayOrder->id, + RazorpayOrderDomainObjectAbstract::AMOUNT_MINOR => $amountMinor, + RazorpayOrderDomainObjectAbstract::CURRENCY => $currency, + RazorpayOrderDomainObjectAbstract::STATUS => 'created', + ]); + + $this->logger->info('Razorpay order created', [ + 'razorpay_order_id' => $razorpayOrder->id, + 'order_id' => $order->getId(), + ]); + + return [ + 'razorpay_order_id' => $razorpayOrder->id, + 'amount_minor' => $amountMinor, + 'currency' => $currency, + ]; + } +} diff --git a/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentCompletionService.php b/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentCompletionService.php new file mode 100644 index 0000000000..66aa373efa --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpayPaymentCompletionService.php @@ -0,0 +1,160 @@ +cache->has($cacheKey)) { + $this->logger->info('Razorpay payment already handled', [ + 'razorpay_payment_id' => $razorpayPaymentId, + ]); + return; + } + + $this->databaseManager->transaction(function () use ($razorpayOrder, $razorpayPaymentId, $razorpaySignature, $cacheKey) { + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findById($razorpayOrder->getOrderId()); + + if (!$order) { + $this->logger->error('Order not found for Razorpay payment', [ + 'razorpay_order_id' => $razorpayOrder->getRazorpayOrderId(), + 'razorpay_payment_id' => $razorpayPaymentId, + ]); + return; + } + + // Guard: only process if order is awaiting payment or payment failed + if (!in_array($order->getPaymentStatus(), [ + OrderPaymentStatus::AWAITING_PAYMENT->name, + OrderPaymentStatus::PAYMENT_FAILED->name, + ], true)) { + throw new CannotAcceptPaymentException( + __('Order is not awaiting payment. Order: :id', ['id' => $order->getId()]) + ); + } + + // Guard: check order has not expired + if ($order->getReservedUntil() && (new Carbon($order->getReservedUntil()))->isPast()) { + throw new CannotAcceptPaymentException( + __('Order has expired. Order: :id', ['id' => $order->getId()]) + ); + } + + // Update the razorpay_orders record + $this->razorpayOrdersRepository->updateWhere( + attributes: [ + RazorpayOrderDomainObjectAbstract::RAZORPAY_PAYMENT_ID => $razorpayPaymentId, + RazorpayOrderDomainObjectAbstract::RAZORPAY_SIGNATURE => $razorpaySignature, + RazorpayOrderDomainObjectAbstract::STATUS => 'paid', + ], + where: [ + RazorpayOrderDomainObjectAbstract::RAZORPAY_ORDER_ID => $razorpayOrder->getRazorpayOrderId(), + ] + ); + + $updatedOrder = $this->updateOrderStatuses($order); + $this->updateAttendeeStatuses($updatedOrder); + $this->quantityUpdateService->updateQuantitiesFromOrder($updatedOrder); + + event(new OrderStatusChangedEvent($updatedOrder)); + + $this->domainEventDispatcherService->dispatch( + new OrderEvent( + type: DomainEventType::ORDER_CREATED, + orderId: $updatedOrder->getId() + ), + ); + + $this->orderApplicationFeeService->createOrderApplicationFee( + orderId: $updatedOrder->getId(), + applicationFeeAmountMinorUnit: 0, + orderApplicationFeeStatus: OrderApplicationFeeStatus::PAID, + paymentMethod: PaymentProviders::RAZORPAY, + currency: $updatedOrder->getCurrency(), + ); + + $this->cache->put($cacheKey, true, 3600); + + $this->logger->info('Razorpay payment completed successfully', [ + 'order_id' => $updatedOrder->getId(), + 'razorpay_payment_id' => $razorpayPaymentId, + ]); + }); + } + + private function updateOrderStatuses(OrderDomainObject $order): OrderDomainObject + { + return $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->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 $updatedOrder): void + { + $this->attendeeRepository->updateWhere( + attributes: ['status' => AttendeeStatus::ACTIVE->name], + where: [ + 'order_id' => $updatedOrder->getId(), + 'status' => AttendeeStatus::AWAITING_PAYMENT->name, + ], + ); + } +} diff --git a/backend/app/Services/Domain/Payment/Razorpay/RazorpayRefundService.php b/backend/app/Services/Domain/Payment/Razorpay/RazorpayRefundService.php new file mode 100644 index 0000000000..4007b13e61 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpayRefundService.php @@ -0,0 +1,61 @@ +getRazorpayPaymentId()) { + throw new \RuntimeException( + __('Cannot refund: no Razorpay payment ID found for this order.') + ); + } + + $api = $this->razorpayClientFactory->create(); + + $this->logger->info('Issuing Razorpay refund', [ + 'razorpay_payment_id' => $razorpayOrder->getRazorpayPaymentId(), + 'amount_minor' => $amount->toMinorUnit(), + ]); + + $refund = $api->refund->create([ + 'payment_id' => $razorpayOrder->getRazorpayPaymentId(), + 'amount' => $amount->toMinorUnit(), + ]); + + $this->logger->info('Razorpay refund issued', [ + 'refund_id' => $refund->id, + 'razorpay_payment_id' => $razorpayOrder->getRazorpayPaymentId(), + 'amount_minor' => $amount->toMinorUnit(), + ]); + + // Mark the razorpay order as refund pending + $this->razorpayOrdersRepository->updateWhere( + attributes: [RazorpayOrderDomainObjectAbstract::STATUS => 'refunded'], + where: [RazorpayOrderDomainObjectAbstract::ID => $razorpayOrder->getId()], + ); + } +} diff --git a/backend/app/Services/Domain/Payment/Razorpay/RazorpaySignatureVerificationService.php b/backend/app/Services/Domain/Payment/Razorpay/RazorpaySignatureVerificationService.php new file mode 100644 index 0000000000..78edae636d --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/RazorpaySignatureVerificationService.php @@ -0,0 +1,72 @@ +razorpayConfigurationService->getKeySecret(); + + if (empty($keySecret)) { + throw new RuntimeException('Razorpay key secret is not configured.'); + } + + $payload = $razorpayOrderId . '|' . $razorpayPaymentId; + $expectedSignature = hash_hmac('sha256', $payload, $keySecret); + + $valid = hash_equals($expectedSignature, $razorpaySignature); + + if (!$valid) { + $this->logger->warning('Razorpay payment signature verification failed', [ + 'razorpay_order_id' => $razorpayOrderId, + 'razorpay_payment_id' => $razorpayPaymentId, + ]); + } + + return $valid; + } + + /** + * Verify the webhook signature. + * + * Razorpay's expected format: + * HMAC-SHA256( raw_body, webhook_secret ) + */ + public function verifyWebhookSignature(string $rawBody, string $signature): bool + { + $webhookSecret = $this->razorpayConfigurationService->getWebhookSecret(); + + if (empty($webhookSecret)) { + throw new RuntimeException('Razorpay webhook secret is not configured.'); + } + + $expectedSignature = hash_hmac('sha256', $rawBody, $webhookSecret); + $valid = hash_equals($expectedSignature, $signature); + + if (!$valid) { + $this->logger->warning('Razorpay webhook signature verification failed'); + } + + return $valid; + } +} diff --git a/backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php b/backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php new file mode 100644 index 0000000000..72cf577d58 --- /dev/null +++ b/backend/app/Services/Infrastructure/Razorpay/RazorpayClientFactory.php @@ -0,0 +1,31 @@ +razorpayConfigurationService->getKeyId(); + $keySecret = $this->razorpayConfigurationService->getKeySecret(); + + if (empty($keyId) || empty($keySecret)) { + throw new RazorpayClientConfigurationException( + __('Razorpay is not configured. Please set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.') + ); + } + + return new Api($keyId, $keySecret); + } +} diff --git a/backend/app/Services/Infrastructure/Razorpay/RazorpayConfigurationService.php b/backend/app/Services/Infrastructure/Razorpay/RazorpayConfigurationService.php new file mode 100644 index 0000000000..90fc08076a --- /dev/null +++ b/backend/app/Services/Infrastructure/Razorpay/RazorpayConfigurationService.php @@ -0,0 +1,33 @@ +config->get('services.razorpay.key_id'); + } + + public function getKeySecret(): ?string + { + return $this->config->get('services.razorpay.key_secret'); + } + + public function getWebhookSecret(): ?string + { + return $this->config->get('services.razorpay.webhook_secret'); + } + + public function isConfigured(): bool + { + return !empty($this->getKeyId()) && !empty($this->getKeySecret()); + } +} diff --git a/backend/composer.json b/backend/composer.json index c12f67d49a..f2067d6117 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -28,6 +28,7 @@ "spatie/icalendar-generator": "^3.0", "spatie/laravel-data": "^4.15", "spatie/laravel-webhook-server": "^3.8", + "razorpay/razorpay": "^2.9", "stripe/stripe-php": "^17.0", "symfony/http-client": "^7.4", "symfony/postmark-mailer": "^7.4" diff --git a/backend/config/services.php b/backend/config/services.php index 44f123a1e7..64bc2591f8 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -49,6 +49,13 @@ // Primary platform for new organizers 'primary_platform' => env('STRIPE_PRIMARY_PLATFORM'), ], + + 'razorpay' => [ + 'key_id' => env('RAZORPAY_KEY_ID'), + 'key_secret' => env('RAZORPAY_KEY_SECRET'), + 'webhook_secret' => env('RAZORPAY_WEBHOOK_SECRET'), + ], + 'open_exchange_rates' => [ 'app_id' => env('OPEN_EXCHANGE_RATES_APP_ID'), ], diff --git a/backend/database/migrations/2026_07_03_000001_create_razorpay_orders_table.php b/backend/database/migrations/2026_07_03_000001_create_razorpay_orders_table.php new file mode 100644 index 0000000000..4b551f2da7 --- /dev/null +++ b/backend/database/migrations/2026_07_03_000001_create_razorpay_orders_table.php @@ -0,0 +1,36 @@ +id(); + + $table->unsignedBigInteger('order_id'); + $table->string('razorpay_order_id')->unique(); // Razorpay order_xxx + $table->string('razorpay_payment_id')->nullable(); // Razorpay pay_xxx (filled on success) + $table->string('razorpay_signature')->nullable(); // HMAC signature from webhook/callback + $table->integer('amount_minor'); // Amount in minor units (e.g. paise for INR) + $table->string('currency', 3); // ISO 4217 currency code (uppercase) + $table->string('status')->default('created'); // created | paid | failed | refunded + + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade'); + $table->index('order_id'); + $table->index('razorpay_order_id'); + $table->index('razorpay_payment_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('razorpay_orders'); + } +}; diff --git a/backend/docs/razorpay-deployment.md b/backend/docs/razorpay-deployment.md new file mode 100644 index 0000000000..4c7b1731bc --- /dev/null +++ b/backend/docs/razorpay-deployment.md @@ -0,0 +1,94 @@ +# Deploying Hi.Events with Razorpay Integration + +This guide provides instructions on how to deploy or update your Hi.Events installation to include the new Razorpay payment integration alongside Stripe. + +## Prerequisites + +Before proceeding, ensure you have: +1. An active Razorpay account. +2. Your Razorpay **Key ID** and **Key Secret**. +3. A configured Razorpay Webhook with a **Webhook Secret**. + +### Razorpay Webhook Configuration +In your Razorpay Dashboard, set up a webhook pointing to your application: +- **Webhook URL**: `https://your-domain.com/api/webhooks/razorpay` +- **Active Events**: + - `payment.captured` + - `payment.failed` + - `refund.processed` +- **Secret**: Generate a strong secret and save it for the `.env` configuration. + +--- + +## Deployment Steps + +### 1. Update Environment Variables + +Update your `.env` files (both `backend/.env` and `docker/all-in-one/.env` if you are using Docker) to include the new Razorpay configuration variables: + +```env +RAZORPAY_KEY_ID=your_razorpay_key_id +RAZORPAY_KEY_SECRET=your_razorpay_key_secret +RAZORPAY_WEBHOOK_SECRET=your_razorpay_webhook_secret +``` + +### 2. Install PHP Dependencies + +The new integration relies on the official `razorpay/razorpay` PHP SDK. You must update your composer dependencies. + +**If running directly on a server:** +```bash +cd backend +composer install --no-interaction --prefer-dist --optimize-autoloader +``` + +**If running via Docker (Development):** +```bash +docker compose -f docker-compose.dev.yml exec backend composer install --no-interaction +``` + +**If running via Docker (All-In-One):** +The Dockerfile will automatically install the dependencies when you rebuild the image. + +### 3. Run Database Migrations + +The Razorpay integration requires a new `razorpay_orders` table to track order statuses, payment IDs, and signatures. + +**If running directly on a server:** +```bash +cd backend +php artisan migrate --force +``` + +**If running via Docker:** +```bash +docker compose -f docker-compose.dev.yml exec backend php artisan migrate --force +``` + +### 4. Rebuild and Restart Containers (Docker Users) + +If you are deploying using Docker, it is highly recommended to rebuild your containers so the frontend can compile the new Razorpay components and the backend can package the new SDK and configurations. + +```bash +# Navigate to your docker directory +cd docker/all-in-one + +# Rebuild and start the containers in the background +docker compose up -d --build +``` + +### 5. Enable Razorpay for Events + +Once the deployment is complete, Razorpay is officially supported by the platform. To use it for a specific event: +1. Log in to the Organizer Dashboard. +2. Navigate to your Event -> **Settings** -> **Payment Settings**. +3. Select **Razorpay** as an enabled payment provider. +4. Save settings. Attendees will now see Razorpay dynamically loaded on the checkout screen. + +--- + +## Troubleshooting + +- **Razorpay SDK Not Loading**: Ensure the frontend can reach `https://checkout.razorpay.com/v1/checkout.js`. +- **Payment Success but Order Not Updating**: Check your webhook logs. Verify that `RAZORPAY_WEBHOOK_SECRET` exactly matches the secret configured in the Razorpay Dashboard. Webhooks are the primary mechanism for finalizing orders. +- **Class Not Found Error**: Run `composer dump-autoload` or ensure `composer install` was executed successfully to load the Razorpay PHP SDK. diff --git a/docker/all-in-one/.env.example b/docker/all-in-one/.env.example index 3a7b4f6130..7065e955b4 100644 --- a/docker/all-in-one/.env.example +++ b/docker/all-in-one/.env.example @@ -52,7 +52,11 @@ STRIPE_PUBLIC_KEY=pk_test_123456789 STRIPE_SECRET_KEY=sk_test_123456789 STRIPE_WEBHOOK_SECRET=whsec_test_123456789 +RAZORPAY_KEY_ID=rzp_test_123456789 +RAZORPAY_KEY_SECRET=secret_test_123456789 +RAZORPAY_WEBHOOK_SECRET=whsec_test_123456789 + # Redis settings REDIS_HOST=redis -REDIS_PASSWORD= +REDIS_PASSWORD=redis-pass REDIS_PORT=6379 diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml index 28f912c617..f0c1620038 100644 --- a/docker/all-in-one/docker-compose.yml +++ b/docker/all-in-one/docker-compose.yml @@ -42,6 +42,9 @@ services: - STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} + - RAZORPAY_KEY_ID=${RAZORPAY_KEY_ID} + - RAZORPAY_KEY_SECRET=${RAZORPAY_KEY_SECRET} + - RAZORPAY_WEBHOOK_SECRET=${RAZORPAY_WEBHOOK_SECRET} - WEBHOOK_QUEUE_NAME=webhook-queue depends_on: diff --git a/docker/backend/docker-compose.yml b/docker/backend/docker-compose.yml index 1beef45898..4e12fe4820 100644 --- a/docker/backend/docker-compose.yml +++ b/docker/backend/docker-compose.yml @@ -21,6 +21,8 @@ services: AWS_PRIVATE_BUCKET: "hievents-private" STRIPE_PUBLIC_KEY: "" STRIPE_SECRET_KEY: "" + RAZORPAY_KEY_ID: "" + RAZORPAY_KEY_SECRET: "" MAIL_MAILER: "smtp" MAIL_HOST: "sandbox.smtp.mailtrap.io" MAIL_PORT: "2525" @@ -40,3 +42,4 @@ services: QUEUE_CONNECTION: "redis" DATABASE_URL: "" STRIPE_WEBHOOK_SECRET: "" + RAZORPAY_WEBHOOK_SECRET: "" diff --git a/docker/development/.env b/docker/development/.env index f93fc21259..9f8c2b5315 100644 --- a/docker/development/.env +++ b/docker/development/.env @@ -6,6 +6,9 @@ APP_DEBUG=true API_URL_CLIENT=https://localhost:8443/api API_URL_SERVER=http://backend:8080 STRIPE_PUBLIC_KEY=pk_test_xxx +RAZORPAY_KEY_ID=rzp_test_xxx +RAZORPAY_KEY_SECRET=secret_test_xxx +RAZORPAY_WEBHOOK_SECRET=whsec_test_xxx FRONTEND_URL=https://localhost:8443 DB_CONNECTION=pgsql diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts index c54d8e5a06..34128dcd7c 100644 --- a/frontend/src/api/order.client.ts +++ b/frontend/src/api/order.client.ts @@ -154,6 +154,25 @@ 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_minor: number, + currency: string, + prefill: { + name: string, + email: string, + }, + }>(`events/${eventId}/order/${orderShortId}/razorpay/order`); + return response.data; + }, + + razorpayPaymentCallback: async (eventId: number, orderShortId: string, payload: { razorpay_order_id: string, razorpay_payment_id: string, razorpay_signature: string }) => { + const response = await publicApi.post<{ status: string }>(`events/${eventId}/order/${orderShortId}/razorpay/callback`, payload); + return response.data; + }, + finaliseOrder: async ( eventId: number, orderShortId: string, diff --git a/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx new file mode 100644 index 0000000000..9b9a1c86be --- /dev/null +++ b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx @@ -0,0 +1,164 @@ +import {useParams, useNavigate} from "react-router"; +import {useCreateRazorpayOrder} from "../../../../../../queries/useCreateRazorpayOrder.ts"; +import {useEffect} from "react"; +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 {validateThemeSettings} from "../../../../../../utilites/themeUtils.ts"; +import {orderClientPublic} from "../../../../../../api/order.client.ts"; +import {showError} from "../../../../../../utilites/notifications.tsx"; +import {trackEvent, AnalyticsEvents} from "../../../../../../utilites/analytics.ts"; + +// Add Razorpay types to window +declare global { + interface Window { + Razorpay: any; + } +} + +interface RazorpayPaymentMethodProps { + enabled: boolean; + setSubmitHandler: (submitHandler: () => () => Promise) => void; +} + +export const RazorpayPaymentMethod = ({enabled, setSubmitHandler}: RazorpayPaymentMethodProps) => { + const {eventId, orderShortId} = useParams(); + const navigate = useNavigate(); + const { + data: razorpayData, + isFetched: isRazorpayFetched, + error: razorpayOrderError + } = useCreateRazorpayOrder(eventId, orderShortId); + + const {data: event} = useGetEventPublic(eventId); + + useEffect(() => { + // Load Razorpay SDK + const script = document.createElement("script"); + script.src = "https://checkout.razorpay.com/v1/checkout.js"; + script.async = true; + document.body.appendChild(script); + + return () => { + document.body.removeChild(script); + }; + }, []); + + useEffect(() => { + if (!isRazorpayFetched || !razorpayData) { + return; + } + + const handlePaymentSubmit = async () => { + return new Promise((resolve, reject) => { + if (!window.Razorpay) { + showError(t`Razorpay SDK failed to load. Please try again.`); + reject(new Error("Razorpay SDK not loaded")); + return; + } + + const themeSettings = validateThemeSettings(event?.settings?.homepage_theme_settings); + + const options = { + key: razorpayData.key_id, + amount: razorpayData.amount_minor, + currency: razorpayData.currency, + name: event?.title || "Hi.Events", + description: t`Order ID: ` + orderShortId, + order_id: razorpayData.razorpay_order_id, + prefill: razorpayData.prefill, + theme: { + color: themeSettings.accent, + }, + handler: async function (response: any) { + try { + // Send callback data to our backend for verification + await orderClientPublic.razorpayPaymentCallback( + Number(eventId), + orderShortId as string, + { + razorpay_order_id: response.razorpay_order_id, + razorpay_payment_id: response.razorpay_payment_id, + razorpay_signature: response.razorpay_signature, + } + ); + + trackEvent(AnalyticsEvents.PURCHASE_COMPLETED, { value: razorpayData.amount_minor }); + navigate(`/checkout/${eventId}/${orderShortId}/summary`); + resolve(); + } catch (error: any) { + showError(error.response?.data?.message || t`Payment verification failed.`); + reject(error); + } + }, + modal: { + ondismiss: function () { + // User closed the modal + reject(new Error("Payment cancelled")); + }, + }, + }; + + const rzp = new window.Razorpay(options); + rzp.on('payment.failed', function (response: any) { + showError(response.error.description || t`Payment failed`); + reject(new Error(response.error.description)); + }); + + rzp.open(); + }); + }; + + setSubmitHandler(() => handlePaymentSubmit); + + // Cleanup handler on unmount + return () => { + setSubmitHandler(() => async () => Promise.resolve()); + }; + }, [isRazorpayFetched, razorpayData, eventId, orderShortId, event, navigate, setSubmitHandler]); + + if (!enabled) { + return ( + + + + ); + } + + if (razorpayOrderError && event) { + return ( + + + + ); + } + + return ( + <> + {!isRazorpayFetched && } + {/* The actual UI is just the "Pay" button in the parent component which triggers the modal */} + {(isRazorpayFetched && razorpayData) && ( +
+ {t`Click the Pay button below to securely complete your payment with Razorpay.`} +
+ )} + + ); +} diff --git a/frontend/src/components/routes/product-widget/Payment/index.tsx b/frontend/src/components/routes/product-widget/Payment/index.tsx index a5fe67c282..1519f52003 100644 --- a/frontend/src/components/routes/product-widget/Payment/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/index.tsx @@ -3,6 +3,7 @@ import {useNavigate, useParams} from "react-router"; import {useGetEventPublic} from "../../../../queries/useGetEventPublic.ts"; import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent"; import {StripePaymentMethod} from "./PaymentMethods/Stripe"; +import {RazorpayPaymentMethod} from "./PaymentMethods/Razorpay"; import {OfflinePaymentMethod} from "./PaymentMethods/Offline"; import {Event} from "../../../../types.ts"; import {Button, Group, Text} from "@mantine/core"; @@ -27,23 +28,26 @@ const Payment = () => { const {data: order, isFetched: isOrderFetched} = useGetOrderPublic(eventId, orderShortId, ['event']); const isLoading = !isOrderFetched; const [isPaymentLoading, setIsPaymentLoading] = useState(false); - const [activePaymentMethod, setActivePaymentMethod] = useState<'STRIPE' | 'OFFLINE' | null>(null); + const [activePaymentMethod, setActivePaymentMethod] = useState<'STRIPE' | 'RAZORPAY' | 'OFFLINE' | null>(null); const [submitHandler, setSubmitHandler] = useState<(() => Promise) | null>(null); const transitionOrderToOfflinePaymentMutation = useTransitionOrderToOfflinePaymentPublic(); const isStripeEnabled = event?.settings?.payment_providers?.includes('STRIPE'); + const isRazorpayEnabled = event?.settings?.payment_providers?.includes('RAZORPAY'); const isOfflineEnabled = event?.settings?.payment_providers?.includes('OFFLINE'); React.useEffect(() => { // Automatically set the first available payment method if (isStripeEnabled) { setActivePaymentMethod('STRIPE'); + } else if (isRazorpayEnabled) { + setActivePaymentMethod('RAZORPAY'); } else if (isOfflineEnabled) { setActivePaymentMethod('OFFLINE'); } else { setActivePaymentMethod(null); // No methods available } - }, [isStripeEnabled, isOfflineEnabled]); + }, [isStripeEnabled, isRazorpayEnabled, isOfflineEnabled]); React.useEffect(() => { // Scroll to top when payment page loads @@ -58,7 +62,7 @@ const Payment = () => { }; const handleSubmit = async () => { - if (activePaymentMethod === 'STRIPE') { + if (activePaymentMethod === 'STRIPE' || activePaymentMethod === 'RAZORPAY') { handleParentSubmit(); } else if (activePaymentMethod === 'OFFLINE') { setIsPaymentLoading(true); @@ -80,7 +84,7 @@ const Payment = () => { } }; - if (!isStripeEnabled && !isOfflineEnabled && isOrderFetched && isEventFetched) { + if (!isStripeEnabled && !isRazorpayEnabled && !isOfflineEnabled && isOrderFetched && isEventFetched) { return ( @@ -102,34 +106,54 @@ const Payment = () => { )} + {isRazorpayEnabled && ( +
+ +
+ )} + {isOfflineEnabled && (
)} - {(isStripeEnabled && isOfflineEnabled) && ( + {((isStripeEnabled ? 1 : 0) + (isRazorpayEnabled ? 1 : 0) + (isOfflineEnabled ? 1 : 0) > 1) && (
{t`Payment method`}
- - + {isStripeEnabled && ( + + )} + {isRazorpayEnabled && ( + + )} + {isOfflineEnabled && ( + + )}
)} diff --git a/frontend/src/queries/useCreateRazorpayOrder.ts b/frontend/src/queries/useCreateRazorpayOrder.ts new file mode 100644 index 0000000000..6704a15d89 --- /dev/null +++ b/frontend/src/queries/useCreateRazorpayOrder.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { orderClientPublic } from '../api/order.client'; + +export const useCreateRazorpayOrder = (eventId?: string, orderShortId?: string) => { + return useQuery({ + queryKey: ['razorpay_order', eventId, orderShortId], + queryFn: async () => { + if (!eventId || !orderShortId) { + return null; + } + return orderClientPublic.createRazorpayOrder(Number(eventId), orderShortId); + }, + enabled: !!eventId && !!orderShortId, + refetchOnWindowFocus: false, + retry: false, + }); +}; From 9bf2382e1b3fcfb20ad71b1f2365e7a919ee3501 Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 16:49:01 +0530 Subject: [PATCH 3/8] Add razorpay PHP SDK dependency Add razorpay/razorpay (v2.9) to backend composer.json and update composer.lock. The lockfile now includes razorpay/razorpay and its dependency rmccue/requests, and updates content-hash and plugin-api-version. This change enables using the Razorpay PHP client in the backend. --- backend/composer.json | 2 +- backend/composer.lock | 155 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 3 deletions(-) diff --git a/backend/composer.json b/backend/composer.json index f2067d6117..a58c175c3d 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -24,11 +24,11 @@ "maatwebsite/excel": "^3.1", "nette/php-generator": "^4.0", "php-open-source-saver/jwt-auth": "^2.1", + "razorpay/razorpay": "2.9", "sentry/sentry-laravel": "^4.13", "spatie/icalendar-generator": "^3.0", "spatie/laravel-data": "^4.15", "spatie/laravel-webhook-server": "^3.8", - "razorpay/razorpay": "^2.9", "stripe/stripe-php": "^17.0", "symfony/http-client": "^7.4", "symfony/postmark-mailer": "^7.4" diff --git a/backend/composer.lock b/backend/composer.lock index 7e0b6a083d..3579716077 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7649da1e3e0f8fad888953eb259e42b7", + "content-hash": "2d23710c397f33ab5de08daa10b35e1d", "packages": [ { "name": "amphp/amp", @@ -6863,6 +6863,71 @@ }, "time": "2025-09-04T20:59:21+00:00" }, + { + "name": "razorpay/razorpay", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/razorpay/razorpay-php.git", + "reference": "a3d7c2bcb416091edd6a76eb5a7600eaf00ac837" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/razorpay/razorpay-php/zipball/a3d7c2bcb416091edd6a76eb5a7600eaf00ac837", + "reference": "a3d7c2bcb416091edd6a76eb5a7600eaf00ac837", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.3", + "rmccue/requests": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "raveren/kint": "1.*" + }, + "type": "library", + "autoload": { + "files": [ + "Deprecated.php" + ], + "psr-4": { + "Razorpay\\Api\\": "src/", + "Razorpay\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Abhay Rana", + "email": "nemo@razorpay.com", + "homepage": "https://captnemo.in", + "role": "Developer" + }, + { + "name": "Shashank Kumar", + "email": "shashank@razorpay.com", + "role": "Developer" + } + ], + "description": "Razorpay PHP Client Library", + "homepage": "https://docs.razorpay.com", + "keywords": [ + "api", + "client", + "php", + "razorpay" + ], + "support": { + "email": "contact@razorpay.com", + "issues": "https://github.com/Razorpay/razorpay-php/issues", + "source": "https://github.com/Razorpay/razorpay-php" + }, + "time": "2023-12-18T04:19:46+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.18", + "source": { + "type": "git", + "url": "https://github.com/WordPress/Requests.git", + "reference": "2e5b8434e0dd54b35bcf1e9a5b52ba2ad84cf773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/Requests/zipball/2e5b8434e0dd54b35bcf1e9a5b52ba2ad84cf773", + "reference": "2e5b8434e0dd54b35bcf1e9a5b52ba2ad84cf773", + "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": "2026-04-30T00:41:23+00:00" + }, { "name": "sabberworm/php-css-parser", "version": "v8.8.0", @@ -13702,5 +13853,5 @@ "ext-xmlwriter": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 74d0ed23438da588137885f357e70a6284518a0f Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 17:33:35 +0530 Subject: [PATCH 4/8] Update docker-compose.yml --- docker/all-in-one/docker-compose.yml | 29 ++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml index f0c1620038..3ecf9f5968 100644 --- a/docker/all-in-one/docker-compose.yml +++ b/docker/all-in-one/docker-compose.yml @@ -4,8 +4,10 @@ services: context: ./../../ dockerfile: Dockerfile.all-in-one restart: unless-stopped - ports: - - "8123:80" + + expose: + - "80" + environment: - VITE_FRONTEND_URL=${VITE_FRONTEND_URL} - VITE_API_URL_CLIENT=${VITE_API_URL_CLIENT} @@ -53,32 +55,51 @@ services: redis: condition: service_healthy + networks: + - default + - dokploy-network + redis: image: redis:7-alpine restart: unless-stopped + healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 + volumes: - redisdata:/data + networks: + - default + postgres: image: postgres:17-alpine restart: unless-stopped + healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-hi-events}" ] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-hi-events}"] interval: 10s timeout: 5s retries: 5 + environment: POSTGRES_DB: ${POSTGRES_DB:-hi-events} POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret} + volumes: - pgdata:/var/lib/postgresql/data + networks: + - default + volumes: pgdata: redisdata: + +networks: + dokploy-network: + external: true \ No newline at end of file From 4f8311871ebc719d50943fa7ddb0b80f845588bd Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 17:42:15 +0530 Subject: [PATCH 5/8] Update index.tsx --- .../routes/event/Settings/Sections/PaymentSettings/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx index 98bd27babc..bad6e38c51 100644 --- a/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/PaymentSettings/index.tsx @@ -85,6 +85,11 @@ export const PaymentAndInvoicingSettings = () => { label: t`Stripe`, description: t`Accept credit card payments with Stripe` }, + { + value: "RAZORPAY", + label: t`Razorpay`, + description: t`Accept credit card, UPI, and other payments with Razorpay` + }, { value: "OFFLINE", label: t`Offline Payments`, From 163a5be481a96df3547e656ec493bcc2779038be Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 17:53:48 +0530 Subject: [PATCH 6/8] Add Razorpay payment routes and webhook Register Razorpay actions and routes in the API: import Razorpay webhook and payment action classes, add public endpoints for creating Razorpay orders and handling payment callbacks, and expose a /webhooks/razorpay webhook endpoint. This enables support for Razorpay as an additional payment gateway. --- backend/routes/api.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/routes/api.php b/backend/routes/api.php index e3947d0804..8fea947e88 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -47,6 +47,7 @@ use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction; use HiEvents\Http\Actions\Common\GetColorThemesAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; +use HiEvents\Http\Actions\Common\Webhooks\RazorpayIncomingWebhookAction; use HiEvents\Http\Actions\Events\CreateEventAction; use HiEvents\Http\Actions\Events\DuplicateEventAction; use HiEvents\Http\Actions\Events\GetEventAction; @@ -94,6 +95,8 @@ use HiEvents\Http\Actions\Orders\Payment\RefundOrderAction; use HiEvents\Http\Actions\Orders\Payment\Stripe\CreatePaymentIntentActionPublic; use HiEvents\Http\Actions\Orders\Payment\Stripe\GetPaymentIntentActionPublic; +use HiEvents\Http\Actions\Orders\Payment\Razorpay\CreateRazorpayOrderActionPublic; +use HiEvents\Http\Actions\Orders\Payment\Razorpay\RazorpayPaymentCallbackActionPublic; use HiEvents\Http\Actions\Orders\Public\AbandonOrderActionPublic; use HiEvents\Http\Actions\Orders\Public\CompleteOrderActionPublic; use HiEvents\Http\Actions\Orders\Public\CreateOrderActionPublic; @@ -527,11 +530,16 @@ function (Router $router): void { $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/callback', RazorpayPaymentCallbackActionPublic::class); + // Questions $router->get('/events/{event_id}/questions', GetQuestionsPublicAction::class); // Webhooks $router->post('/webhooks/stripe', StripeIncomingWebhookAction::class); + $router->post('/webhooks/razorpay', RazorpayIncomingWebhookAction::class); // Check-In $router->get('/check-in-lists/{check_in_list_short_id}', GetCheckInListPublicAction::class); From 6fb0124260925e447bf6a882846dcc49a021830d Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 18:01:05 +0530 Subject: [PATCH 7/8] Prevent duplicate Razorpay SDK loads Add an id to the injected Razorpay script and early-return from the effect if an element with that id already exists. Also remove the cleanup that removed the script on unmount so the shared SDK isn't removed when individual components unmount. This prevents multiple injections and related side effects when the component mounts repeatedly. --- .../Payment/PaymentMethods/Razorpay/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 9b9a1c86be..a133be38fc 100644 --- a/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Razorpay/index.tsx @@ -37,15 +37,15 @@ export const RazorpayPaymentMethod = ({enabled, setSubmitHandler}: RazorpayPayme const {data: event} = useGetEventPublic(eventId); useEffect(() => { - // Load Razorpay SDK + if (document.getElementById('razorpay-sdk')) { + return; + } + const script = document.createElement("script"); + script.id = 'razorpay-sdk'; script.src = "https://checkout.razorpay.com/v1/checkout.js"; script.async = true; document.body.appendChild(script); - - return () => { - document.body.removeChild(script); - }; }, []); useEffect(() => { From 683ba29e24eaf8b9f8750d60fa0b111aa5621d6d Mon Sep 17 00:00:00 2001 From: Ritaban Ghosh Date: Fri, 3 Jul 2026 18:10:02 +0530 Subject: [PATCH 8/8] Validate session and order status for Razorpay Tighten pre-checks in CreateRazorpayOrderHandler: use getSessionId() for session verification and throw an UnauthorizedException with a user-friendly message when session verification fails. Add an explicit check that the order status is RESERVED and not expired, throwing a ResourceConflictException if the order is expired or in an invalid state. These changes prevent creating Razorpay orders for invalid or expired orders. --- .../Order/Payment/Razorpay/CreateRazorpayOrderHandler.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php index 111dbe6ab6..ef5e051e3e 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/CreateRazorpayOrderHandler.php @@ -30,8 +30,12 @@ public function handle(string $orderShortId): CreateRazorpayOrderResponseDTO { $order = $this->orderRepository->findByShortId($orderShortId); - if (!$order || !$this->checkoutSessionManagementService->verifySession($order->getSessionIdentifier())) { - throw new ResourceNotFoundException('Order not found or invalid session'); + if (!$order || !$this->checkoutSessionManagementService->verifySession($order->getSessionId() ?? '')) { + throw new \HiEvents\Exceptions\UnauthorizedException(__('Sorry, we could not verify your session. Please create a new order.')); + } + + if ($order->getStatus() !== \HiEvents\DomainObjects\Status\OrderStatus::RESERVED->name || $order->isReservedOrderExpired()) { + throw new \HiEvents\Exceptions\ResourceConflictException(__('Sorry, your order is expired or not in a valid state.')); } // Check if a Razorpay order already exists for this order