diff --git a/backend/.env.testing b/backend/.env.testing new file mode 100644 index 0000000000..254a18d342 --- /dev/null +++ b/backend/.env.testing @@ -0,0 +1,2 @@ +DB_CONNECTION=sqlite +DB_DATABASE=:memory: diff --git a/backend/app/DomainObjects/Generated/AccountRazorpayPlatformDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountRazorpayPlatformDomainObjectAbstract.php index 3960e87e87..495c029800 100644 --- a/backend/app/DomainObjects/Generated/AccountRazorpayPlatformDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AccountRazorpayPlatformDomainObjectAbstract.php @@ -15,6 +15,7 @@ abstract class AccountRazorpayPlatformDomainObjectAbstract extends \HiEvents\Dom final public const RAZORPAY_ACCOUNT_ID = 'razorpay_account_id'; final public const STATUS = 'status'; final public const RAZORPAY_ACCOUNT_DETAILS = 'razorpay_account_details'; + final public const ONBOARDING_DATA = 'onboarding_data'; final public const ACTIVATED_AT = 'activated_at'; final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; @@ -25,6 +26,7 @@ abstract class AccountRazorpayPlatformDomainObjectAbstract extends \HiEvents\Dom protected ?string $razorpay_account_id = null; protected string $status = 'created'; protected array|string|null $razorpay_account_details = null; + protected array|string|null $onboarding_data = null; protected ?string $activated_at = null; protected ?string $created_at = null; protected ?string $updated_at = null; @@ -38,6 +40,7 @@ public function toArray(): array 'razorpay_account_id' => $this->razorpay_account_id ?? null, 'status' => $this->status ?? null, 'razorpay_account_details' => $this->razorpay_account_details ?? null, + 'onboarding_data' => $this->onboarding_data ?? null, 'activated_at' => $this->activated_at ?? null, 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, @@ -100,6 +103,17 @@ public function getRazorpayAccountDetails(): array|string|null return $this->razorpay_account_details; } + public function setOnboardingData(array|string|null $onboarding_data): self + { + $this->onboarding_data = $onboarding_data; + return $this; + } + + public function getOnboardingData(): array|string|null + { + return $this->onboarding_data; + } + public function setActivatedAt(?string $activated_at): self { $this->activated_at = $activated_at; diff --git a/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpayBusinessStageAction.php b/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpayBusinessStageAction.php new file mode 100644 index 0000000000..f2d665c584 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpayBusinessStageAction.php @@ -0,0 +1,43 @@ +validated('email'), + phone: $request->validated('phone'), + legalBusinessName: $request->validated('legalBusinessName'), + businessType: $request->validated('businessType'), + contactName: $request->validated('contactName'), + registeredAddress: new RegisteredAddressDTO( + street1: $request->validated('registeredAddress.street1'), + street2: $request->validated('registeredAddress.street2'), + city: $request->validated('registeredAddress.city'), + state: $request->validated('registeredAddress.state'), + postalCode: $request->validated('registeredAddress.postalCode'), + country: $request->validated('registeredAddress.country'), + ), + pan: $request->validated('pan'), + gst: $request->validated('gst'), + ); + + $response = $this->handler->handle($dto); + + return response()->json($response->toArray()); + } +} diff --git a/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpaySettlementStageAction.php b/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpaySettlementStageAction.php new file mode 100644 index 0000000000..4893618ee1 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpaySettlementStageAction.php @@ -0,0 +1,33 @@ +validated('settlement.accountNumber'), + ifscCode: $request->validated('settlement.ifscCode'), + beneficiaryName: $request->validated('settlement.beneficiaryName'), + ) + ); + + $response = $this->handler->handle($dto); + + return response()->json($response->toArray()); + } +} diff --git a/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpayStakeholderStageAction.php b/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpayStakeholderStageAction.php new file mode 100644 index 0000000000..2505ccfdf6 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Razorpay/UpdateRazorpayStakeholderStageAction.php @@ -0,0 +1,41 @@ +validated('stakeholder.name'), + email: $request->validated('stakeholder.email'), + residentialAddress: new ResidentialAddressDTO( + street: $request->validated('stakeholder.residentialAddress.street'), + city: $request->validated('stakeholder.residentialAddress.city'), + state: $request->validated('stakeholder.residentialAddress.state'), + postalCode: $request->validated('stakeholder.residentialAddress.postalCode'), + country: $request->validated('stakeholder.residentialAddress.country'), + ), + pan: $request->validated('stakeholder.pan'), + ) + ); + + $response = $this->handler->handle($dto); + + return response()->json($response->toArray()); + } +} diff --git a/backend/app/Http/Requests/Accounts/Razorpay/RazorpayBusinessStageRequest.php b/backend/app/Http/Requests/Accounts/Razorpay/RazorpayBusinessStageRequest.php new file mode 100644 index 0000000000..d9c33972a2 --- /dev/null +++ b/backend/app/Http/Requests/Accounts/Razorpay/RazorpayBusinessStageRequest.php @@ -0,0 +1,42 @@ + ['required', 'email'], + 'phone' => ['required', 'string', 'min:10'], + 'legalBusinessName' => ['required', 'string', 'max:255'], + 'businessType' => ['required', Rule::in(['partnership', 'proprietorship', 'private_limited', 'public_limited'])], + 'contactName' => ['required', 'string', 'max:255'], + 'registeredAddress' => ['required', 'array'], + 'registeredAddress.street1' => ['required', 'string', 'max:255'], + 'registeredAddress.street2' => ['nullable', 'string', 'max:255'], + 'registeredAddress.city' => ['required', 'string', 'max:255'], + 'registeredAddress.state' => ['required', 'string', 'max:255'], + 'registeredAddress.postalCode' => ['required', 'string', 'max:10'], + 'registeredAddress.country' => ['required', 'string', 'size:2', 'in:IN'], + 'pan' => ['required', 'string', 'size:10'], + 'gst' => ['nullable', 'string', 'max:15'], + ]; + } + + public function messages(): array + { + return [ + 'pan.size' => __('The PAN must be exactly 10 characters.'), + 'registeredAddress.country.in' => __('Only Indian addresses are supported.'), + ]; + } +} diff --git a/backend/app/Http/Requests/Accounts/Razorpay/RazorpaySettlementStageRequest.php b/backend/app/Http/Requests/Accounts/Razorpay/RazorpaySettlementStageRequest.php new file mode 100644 index 0000000000..6d08865753 --- /dev/null +++ b/backend/app/Http/Requests/Accounts/Razorpay/RazorpaySettlementStageRequest.php @@ -0,0 +1,30 @@ + ['required', 'array'], + 'settlement.accountNumber' => ['required', 'string', 'max:30'], + 'settlement.ifscCode' => ['required', 'string', 'size:11'], + 'settlement.beneficiaryName' => ['required', 'string', 'max:255'], + ]; + } + + public function messages(): array + { + return [ + 'settlement.ifscCode.size' => __('The IFSC code must be exactly 11 characters.'), + ]; + } +} diff --git a/backend/app/Http/Requests/Accounts/Razorpay/RazorpayStakeholderStageRequest.php b/backend/app/Http/Requests/Accounts/Razorpay/RazorpayStakeholderStageRequest.php new file mode 100644 index 0000000000..b728900893 --- /dev/null +++ b/backend/app/Http/Requests/Accounts/Razorpay/RazorpayStakeholderStageRequest.php @@ -0,0 +1,37 @@ + ['required', 'array'], + 'stakeholder.name' => ['required', 'string', 'max:255'], + 'stakeholder.email' => ['required', 'email'], + 'stakeholder.pan' => ['required', 'string', 'size:10'], + 'stakeholder.residentialAddress' => ['required', 'array'], + 'stakeholder.residentialAddress.street' => ['required', 'string', 'max:255'], + 'stakeholder.residentialAddress.city' => ['required', 'string', 'max:255'], + 'stakeholder.residentialAddress.state' => ['required', 'string', 'max:255'], + 'stakeholder.residentialAddress.postalCode' => ['required', 'string', 'max:10'], + 'stakeholder.residentialAddress.country' => ['required', 'string', 'size:2', 'in:IN'], + ]; + } + + public function messages(): array + { + return [ + 'stakeholder.pan.size' => __('The stakeholder PAN must be exactly 10 characters.'), + 'stakeholder.residentialAddress.country.in' => __('Only Indian addresses are supported.'), + ]; + } +} diff --git a/backend/app/Models/AccountRazorpayPlatform.php b/backend/app/Models/AccountRazorpayPlatform.php index 7f5ba85b5d..bd0ef52ad2 100644 --- a/backend/app/Models/AccountRazorpayPlatform.php +++ b/backend/app/Models/AccountRazorpayPlatform.php @@ -9,11 +9,13 @@ class AccountRazorpayPlatform extends BaseModel 'razorpay_account_id', 'status', 'razorpay_account_details', + 'onboarding_data', 'activated_at', ]; protected $casts = [ 'razorpay_account_details' => 'array', + 'onboarding_data' => 'array', 'activated_at' => 'datetime', ]; diff --git a/backend/app/Repository/Eloquent/AccountRazorpayPlatformRepository.php b/backend/app/Repository/Eloquent/AccountRazorpayPlatformRepository.php index 1052d041a6..1c0e90cfb3 100644 --- a/backend/app/Repository/Eloquent/AccountRazorpayPlatformRepository.php +++ b/backend/app/Repository/Eloquent/AccountRazorpayPlatformRepository.php @@ -24,6 +24,12 @@ public function findByAccountId(int $accountId): ?AccountRazorpayPlatformDomainO return $results->first() ?: null; } + public function findByRazorpayAccountId(string $razorpayAccountId): ?AccountRazorpayPlatformDomainObject + { + $results = $this->findWhere(['razorpay_account_id' => $razorpayAccountId]); + return $results->first() ?: null; + } + public function updateById(int $id, array $data): bool { return (bool) $this->model diff --git a/backend/app/Repository/Interfaces/AccountRazorpayPlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountRazorpayPlatformRepositoryInterface.php index 2d99cd9233..e84a086909 100644 --- a/backend/app/Repository/Interfaces/AccountRazorpayPlatformRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountRazorpayPlatformRepositoryInterface.php @@ -8,5 +8,7 @@ interface AccountRazorpayPlatformRepositoryInterface extends RepositoryInterface { public function findByAccountId(int $accountId): ?AccountRazorpayPlatformDomainObject; + public function findByRazorpayAccountId(string $razorpayAccountId): ?AccountRazorpayPlatformDomainObject; + public function updateById(int $id, array $data): bool; } \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/DTO/RazorpayLinkedAccountDTO.php b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/DTO/RazorpayLinkedAccountDTO.php index 48b5b9bf33..88e057bdfb 100644 --- a/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/DTO/RazorpayLinkedAccountDTO.php +++ b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/DTO/RazorpayLinkedAccountDTO.php @@ -13,5 +13,6 @@ public function __construct( public readonly ?string $email, public readonly ?string $legalBusinessName, public readonly string $country = 'IN', + public readonly ?array $onboardingData = null, ) {} } \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/DTO/UpdateRazorpayBusinessStageDTO.php b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/DTO/UpdateRazorpayBusinessStageDTO.php new file mode 100644 index 0000000000..e3b571cd52 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/DTO/UpdateRazorpayBusinessStageDTO.php @@ -0,0 +1,23 @@ +map(function (AccountRazorpayPlatformDomainObject $platform) use ($account){ + $onboardingData = $platform->getOnboardingData(); + if (is_string($onboardingData)) { + $onboardingData = json_decode($onboardingData, true) ?: null; + } + return new RazorpayLinkedAccountDTO( id: $platform->getId(), status: $platform->getStatus(), @@ -43,6 +48,7 @@ public function handle(int $accountId): GetRazorpayLinkedAccountsResponseDTO email: $account->getEmail(), legalBusinessName: $account->getName(), country: 'IN', + onboardingData: $onboardingData, ); }); diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpayBusinessStageHandler.php b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpayBusinessStageHandler.php new file mode 100644 index 0000000000..3936a5f545 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpayBusinessStageHandler.php @@ -0,0 +1,256 @@ +config->get('app.saas_mode_enabled')) { + throw new SaasModeEnabledException( + __('This feature is only available in SaaS mode.') + ); + } + + $localAccount = $this->databaseManager->transaction(function () use ($command) { + $account = $this->accountRepository->findById($command->accountId); + + if (!$this->isEligible($account)) { + abort(403, __('This account is not eligible for onboarding.')); + } + + $existing = $this->accountRazorpayPlatformRepository->findByAccountId($command->accountId); + + $onboardingData = $existing ? ($existing->getOnboardingData() ?? []) : []; + if (is_string($onboardingData)) { + $onboardingData = json_decode($onboardingData, true) ?: []; + } + + $onboardingData['business'] = $command->toArray(); + + if ($existing) { + $this->accountRazorpayPlatformRepository->updateById( + $existing->getId(), + ['onboarding_data' => json_encode($onboardingData)] + ); + return $this->accountRazorpayPlatformRepository->findById($existing->getId()); + } + + return $this->accountRazorpayPlatformRepository->create([ + 'account_id' => $command->accountId, + 'status' => 'initiated', + 'onboarding_data' => json_encode($onboardingData), + ]); + }); + + if ($localAccount->getStatus() === 'active') { + return $this->buildResponse($localAccount, true); + } + + $client = $this->razorpayClientFactory->create(); + + try { + $razorpayAccountId = $localAccount->getRazorpayAccountId(); + + if (!$razorpayAccountId) { + $razorpayAccountId = $this->createRemoteAccount($client, $command, $localAccount); + } else { + $this->updateRemoteAccount($client, $razorpayAccountId, $command); + } + + return $this->buildResponse($localAccount, false, $razorpayAccountId); + } catch (Throwable $e) { + $this->failLocal($localAccount, $e); + + throw new RazorpayAccountSetupException( + $this->mapToUserMessage($e), + previous: $e + ); + } + } + + private function updateRemoteAccount(RazorpayApiClient $client, string $razorpayAccountId, UpdateRazorpayBusinessStageDTO $command): void + { + $client->updateLinkedAccount($razorpayAccountId, [ + 'email' => $command->email ?? '', + 'phone' => $command->phone ?? '', + 'legal_business_name' => $command->legalBusinessName ?? '', + 'business_type' => $command->businessType ?? 'partnership', + 'contact_name' => $command->contactName ?? '', + 'profile' => [ + 'category' => $command->profileCategory ?? 'healthcare', + 'subcategory' => $command->profileSubcategory ?? 'clinic', + 'addresses' => [ + 'registered' => [ + 'street1' => $command->registeredAddress?->street1 ?? '', + 'street2' => $command->registeredAddress?->street2 ?? '', + 'city' => $command->registeredAddress?->city ?? '', + 'state' => $command->registeredAddress?->state ?? '', + 'postal_code' => $command->registeredAddress?->postalCode ?? '', + 'country' => $command->registeredAddress?->country ?? 'IN', + ], + ], + ], + ]); + } + + private function createRemoteAccount( + RazorpayApiClient $client, + UpdateRazorpayBusinessStageDTO $command, + object $localAccount + ): string { + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + ['status' => 'creating_remote'] + ); + + try { + $account = $client->createLinkedAccount([ + 'email' => $command->email ?? '', + 'phone' => $command->phone ?? '', + 'type' => 'route', + 'legal_business_name' => $command->legalBusinessName ?? '', + 'business_type' => $command->businessType ?? 'partnership', + 'contact_name' => $command->contactName ?? '', + 'profile' => [ + 'category' => $command->profileCategory ?? 'healthcare', + 'subcategory' => $command->profileSubcategory ?? 'clinic', + 'addresses' => [ + 'registered' => [ + 'street1' => $command->registeredAddress?->street1 ?? '', + 'street2' => $command->registeredAddress?->street2 ?? '', + 'city' => $command->registeredAddress?->city ?? '', + 'state' => $command->registeredAddress?->state ?? '', + 'postal_code' => $command->registeredAddress?->postalCode ?? '', + 'country' => $command->registeredAddress?->country ?? 'IN', + ], + ], + ], + ]); + + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + [ + 'razorpay_account_id' => $account->id, + 'razorpay_account_details' => json_encode($account->toArray()), + 'status' => 'created', + ] + ); + + return $account->id; + + } catch (BadRequestError $e) { + if ($this->isDuplicateEmailError($e)) { + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + [ + 'status' => 'orphaned_remote', + 'error' => $e->getMessage(), + ] + ); + + $this->logger->error('Duplicate Razorpay account without recoverable ID', [ + 'email' => $command->email, + 'account_id' => $command->accountId, + 'razorpay_error' => $e->getMessage(), + ]); + + throw new RazorpayAccountSetupException( + __('This account is already registered. Please contact support.') + ); + } + + throw $e; + } + } + + private function failLocal(object $localAccount, Throwable $e): void + { + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + [ + 'status' => 'failed', + 'error' => json_encode([ + 'raw' => $e->getMessage(), + 'type' => get_class($e), + ]), + ] + ); + + $this->logger->error('Razorpay onboarding business stage failed', [ + 'account_id' => $localAccount->getId(), + 'error' => $e->getMessage(), + ]); + } + + private function mapToUserMessage(Throwable $e): string + { + if ($e instanceof RazorpayAccountSetupException) { + return $e->getMessage(); + } + + $msg = $e->getMessage(); + + if (str_contains($msg, 'addresses')) { + return __('Please provide a valid business address.'); + } + + if (str_contains($msg, 'email')) { + return __('Please provide a valid email address.'); + } + + if (str_contains($msg, 'phone')) { + return __('Please provide a valid phone number.'); + } + + if ($this->isDuplicateEmailError($e)) { + return __('This account is already registered. Please contact support.'); + } + + return __('We could not complete the onboarding. Please try again.'); + } + + private function buildResponse(object $localAccount, bool $completed, ?string $razorpayAccountId = null): CreateRazorpayLinkedAccountResponse + { + return new CreateRazorpayLinkedAccountResponse( + linkedAccountId: $localAccount->getId(), + razorpayAccountId: $razorpayAccountId ?: ($localAccount->getRazorpayAccountId() ?? ''), + setupCompleted: $completed, + ); + } + + private function isEligible(AccountDomainObject $account): bool + { + return $account->getCurrencyCode() === 'INR'; + } + + private function isDuplicateEmailError(Throwable $e): bool + { + return $e instanceof RazorpayAccountSetupException; + } +} diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpaySettlementStageHandler.php b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpaySettlementStageHandler.php new file mode 100644 index 0000000000..c154cacb3f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpaySettlementStageHandler.php @@ -0,0 +1,179 @@ +databaseManager->transaction(function () use ($command) { + $existing = $this->accountRazorpayPlatformRepository->findByAccountId($command->accountId); + + if (!$existing) { + abort(404, __('Razorpay onboarding not initiated.')); + } + + $onboardingData = $existing->getOnboardingData() ?? []; + if (is_string($onboardingData)) { + $onboardingData = json_decode($onboardingData, true) ?: []; + } + + $onboardingData['settlement'] = $command->settlement->toArray(); + + $this->accountRazorpayPlatformRepository->updateById( + $existing->getId(), + ['onboarding_data' => json_encode($onboardingData)] + ); + + return $this->accountRazorpayPlatformRepository->findById($existing->getId()); + }); + + if ($localAccount->getStatus() === 'active') { + return $this->buildResponse($localAccount, true); + } + + $razorpayAccountId = $localAccount->getRazorpayAccountId(); + if (!$razorpayAccountId) { + abort(400, __('Razorpay account ID is missing. Cannot proceed to settlement setup.')); + } + + $client = $this->razorpayClientFactory->create(); + + try { + $onboardingData = $localAccount->getOnboardingData() ?? []; + if (is_string($onboardingData)) { + $onboardingData = json_decode($onboardingData, true) ?: []; + } + $productId = $onboardingData['product_id'] ?? null; + + if ($productId) { + $this->updateProduct($client, $razorpayAccountId, $productId, $command); + } else { + $productId = $this->configureProduct($client, $razorpayAccountId, $command); + + // Save the product ID + $onboardingData['product_id'] = $productId; + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + ['onboarding_data' => json_encode($onboardingData)] + ); + } + + $productConfig = $client->fetchProductConfiguration($razorpayAccountId, $productId); + $isActive = $productConfig->activation_status === 'activated'; + + $this->accountRazorpayPlatformRepository->updateWhere( + ['id' => $localAccount->getId()], + [ + 'status' => $isActive ? 'active' : 'pending_activation', + 'activated_at' => $isActive ? now()->toDateTimeString() : null, + ] + ); + + return $this->buildResponse($localAccount, $isActive); + } catch (Throwable $e) { + $this->failLocal($localAccount, $e); + throw new RazorpayAccountSetupException( + $e->getMessage(), + previous: $e + ); + } + } + + private function updateProduct(RazorpayApiClient $client, string $razorpayAccountId, string $productId, UpdateRazorpaySettlementStageDTO $command): void + { + if ($command->settlement) { + $client->updateProductConfiguration($razorpayAccountId, $productId, [ + 'settlements' => [ + 'account_number' => $command->settlement->accountNumber, + 'ifsc_code' => $command->settlement->ifscCode, + 'beneficiary_name' => $command->settlement->beneficiaryName, + ], + 'tnc_accepted' => true, + ]); + + $this->logger->info('Product configuration updated with settlement', [ + 'razorpay_account_id' => $razorpayAccountId, + 'product_id' => $productId, + ]); + } + } + + private function configureProduct(RazorpayApiClient $client, string $razorpayAccountId, UpdateRazorpaySettlementStageDTO $command): string + { + $product = $client->requestProductConfiguration($razorpayAccountId, [ + 'product_name' => 'route', + 'tnc_accepted' => true, + ]); + $productId = $product->id; + + $this->logger->info('Product configuration requested', [ + 'razorpay_account_id' => $razorpayAccountId, + 'product_id' => $productId, + ]); + + if ($command->settlement) { + $client->updateProductConfiguration($razorpayAccountId, $productId, [ + 'settlements' => [ + 'account_number' => $command->settlement->accountNumber, + 'ifsc_code' => $command->settlement->ifscCode, + 'beneficiary_name' => $command->settlement->beneficiaryName, + ], + 'tnc_accepted' => true, + ]); + + $this->logger->info('Product configuration updated with settlement', [ + 'razorpay_account_id' => $razorpayAccountId, + 'product_id' => $productId, + ]); + } + + return $productId; + } + + private function failLocal(object $localAccount, Throwable $e): void + { + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + [ + 'status' => 'failed', + 'error' => json_encode([ + 'raw' => $e->getMessage(), + 'type' => get_class($e), + ]), + ] + ); + + $this->logger->error('Razorpay onboarding settlement stage failed', [ + 'account_id' => $localAccount->getId(), + 'error' => $e->getMessage(), + ]); + } + + private function buildResponse(object $localAccount, bool $completed): CreateRazorpayLinkedAccountResponse + { + return new CreateRazorpayLinkedAccountResponse( + linkedAccountId: $localAccount->getId(), + razorpayAccountId: $localAccount->getRazorpayAccountId(), + setupCompleted: $completed, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpayStakeholderStageHandler.php b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpayStakeholderStageHandler.php new file mode 100644 index 0000000000..d136f1d567 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Payment/Razorpay/UpdateRazorpayStakeholderStageHandler.php @@ -0,0 +1,188 @@ +databaseManager->transaction(function () use ($command) { + $existing = $this->accountRazorpayPlatformRepository->findByAccountId($command->accountId); + + if (!$existing) { + abort(404, __('Razorpay onboarding not initiated.')); + } + + $onboardingData = $existing->getOnboardingData() ?? []; + if (is_string($onboardingData)) { + $onboardingData = json_decode($onboardingData, true) ?: []; + } + + $onboardingData['stakeholder'] = $command->stakeholder->toArray(); + + $this->accountRazorpayPlatformRepository->updateById( + $existing->getId(), + ['onboarding_data' => json_encode($onboardingData)] + ); + + return $this->accountRazorpayPlatformRepository->findById($existing->getId()); + }); + + if ($localAccount->getStatus() === 'active') { + return $this->buildResponse($localAccount, true); + } + + $razorpayAccountId = $localAccount->getRazorpayAccountId(); + if (!$razorpayAccountId) { + abort(400, __('Razorpay account ID is missing. Cannot proceed to stakeholder setup.')); + } + + $client = $this->razorpayClientFactory->create(); + + try { + $onboardingData = $localAccount->getOnboardingData() ?? []; + if (is_string($onboardingData)) { + $onboardingData = json_decode($onboardingData, true) ?: []; + } + $stakeholderId = $onboardingData['stakeholder_id'] ?? null; + + if ($stakeholderId) { + $this->updateStakeholder($client, $razorpayAccountId, $stakeholderId, $command->stakeholder); + } else { + $stakeholderId = $this->createStakeholder($client, $razorpayAccountId, $command->stakeholder); + + // Save the stakeholder ID + $onboardingData['stakeholder_id'] = $stakeholderId; + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + ['onboarding_data' => json_encode($onboardingData)] + ); + } + + return $this->buildResponse($localAccount, false); + } catch (Throwable $e) { + $this->failLocal($localAccount, $e); + throw new RazorpayAccountSetupException( + $e->getMessage(), + previous: $e + ); + } + } + + private function updateStakeholder(RazorpayApiClient $client, string $razorpayAccountId, string $stakeholderId, object $stakeholderData): void + { + try { + $client->updateStakeholder($razorpayAccountId, $stakeholderId, [ + 'name' => $stakeholderData->name, + 'email' => $stakeholderData->email, + 'addresses' => [ + 'residential' => [ + 'street' => $stakeholderData->residentialAddress->street ?? '', + 'city' => $stakeholderData->residentialAddress->city ?? '', + 'state' => $stakeholderData->residentialAddress->state ?? '', + 'postal_code' => $stakeholderData->residentialAddress->postalCode ?? '', + 'country' => $stakeholderData->residentialAddress->country ?? 'IN', + ], + ], + 'kyc' => [ + 'pan' => $stakeholderData->pan ?? '', + ], + 'notes' => $stakeholderData->notes ?? [], + ]); + $this->logger->info('Stakeholder updated', ['razorpay_account_id' => $razorpayAccountId, 'stakeholder_id' => $stakeholderId]); + } catch (Error $e) { + throw RazorpayAccountSetupException::fromRazorpayError($e, 'updating stakeholder'); + } + } + + private function createStakeholder(RazorpayApiClient $client, string $razorpayAccountId, object $stakeholderData): string + { + try { + $response = $client->createStakeholder($razorpayAccountId, [ + 'name' => $stakeholderData->name, + 'email' => $stakeholderData->email, + 'addresses' => [ + 'residential' => [ + 'street' => $stakeholderData->residentialAddress->street ?? '', + 'city' => $stakeholderData->residentialAddress->city ?? '', + 'state' => $stakeholderData->residentialAddress->state ?? '', + 'postal_code' => $stakeholderData->residentialAddress->postalCode ?? '', + 'country' => $stakeholderData->residentialAddress->country ?? 'IN', + ], + ], + 'kyc' => [ + 'pan' => $stakeholderData->pan ?? '', + ], + 'notes' => $stakeholderData->notes ?? [], + ]); + $this->logger->info('Stakeholder created', ['razorpay_account_id' => $razorpayAccountId]); + return $response->id; + } catch (BadRequestError $e) { + if ($this->isStakeholderDuplicateError($e)) { + $this->logger->warning('Stakeholder already exists, skipping creation', [ + 'razorpay_account_id' => $razorpayAccountId, + 'error' => $e->getMessage(), + ]); + return ''; // We might not have the ID here, which could be problematic for updates later if they retry. Ideally they don't hit this. + } else { + throw RazorpayAccountSetupException::fromRazorpayError($e, 'creating stakeholder'); + } + } catch (Error $e) { + throw RazorpayAccountSetupException::fromRazorpayError($e, 'creating stakeholder'); + } + } + + private function failLocal(object $localAccount, Throwable $e): void + { + $this->accountRazorpayPlatformRepository->updateById( + $localAccount->getId(), + [ + 'status' => 'failed', + 'error' => json_encode([ + 'raw' => $e->getMessage(), + 'type' => get_class($e), + ]), + ] + ); + + $this->logger->error('Razorpay onboarding stakeholder stage failed', [ + 'account_id' => $localAccount->getId(), + 'error' => $e->getMessage(), + ]); + } + + private function buildResponse(object $localAccount, bool $completed): CreateRazorpayLinkedAccountResponse + { + return new CreateRazorpayLinkedAccountResponse( + linkedAccountId: $localAccount->getId(), + razorpayAccountId: $localAccount->getRazorpayAccountId(), + setupCompleted: $completed, + ); + } + + private function isStakeholderDuplicateError(BadRequestError $e): bool + { + $message = $e->getMessage(); + return str_contains($message, 'already exists') || str_contains($message, 'duplicate'); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php index 982ecf6cc1..24c4a5de94 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandler.php @@ -10,6 +10,7 @@ use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayRefundHandler; use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentFailedHandler; use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayPaymentAuthorizedHandler; +use HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayAccountStatusHandler; use Illuminate\Cache\Repository; use Illuminate\Log\Logger; use JsonException; @@ -24,6 +25,10 @@ class RazorpayWebhookHandler 'refund.processed', 'payment.failed', 'payment.authorized', + 'account.instantiated', + 'account.under_review', + 'account.funds_on_hold', + 'account.status_updated', ]; public function __construct( @@ -32,6 +37,7 @@ public function __construct( private readonly RazorpayRefundHandler $refundHandler, private readonly RazorpayPaymentFailedHandler $paymentFailedHandler, private readonly RazorpayPaymentAuthorizedHandler $paymentAuthorizedHandler, + private readonly RazorpayAccountStatusHandler $accountStatusHandler, private readonly RazorpayPaymentVerificationService $razorpayPaymentService, private readonly Logger $logger, private readonly Repository $cache, @@ -76,6 +82,7 @@ public function handle(string $payload, string $signature): void 'payment.captured', 'payment.failed', 'payment.authorized' => $envelope->payload->payment->id, 'order.paid' => $envelope->payload->order->id, 'refund.processed' => $envelope->payload->refund->id, + 'account.instantiated', 'account.under_review', 'account.funds_on_hold', 'account.status_updated' => $data['account_id'] . '_' . $event . '_' . $data['created_at'], default => null, }; @@ -105,6 +112,7 @@ public function handle(string $payload, string $signature): void 'refund.processed' => $this->refundHandler->handleEvent($envelope->payload), 'payment.failed' => $this->paymentFailedHandler->handleEvent($envelope->payload), 'payment.authorized' => $this->paymentAuthorizedHandler->handleEvent($envelope->payload), + 'account.instantiated', 'account.under_review', 'account.funds_on_hold', 'account.status_updated' => $this->accountStatusHandler->handleEvent($event, $envelope->payload), default => $this->logger->debug('No handler for event', ['event' => $event]), }; diff --git a/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayAccountWebhookDTO.php b/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayAccountWebhookDTO.php new file mode 100644 index 0000000000..fa037048d2 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/DTOs/RazorpayAccountWebhookDTO.php @@ -0,0 +1,18 @@ + RazorpayRefundPayload::from([ 'refund' => $payloadData['refund']['entity'], ]), + 'account.instantiated', 'account.under_review', 'account.funds_on_hold', 'account.status_updated' => RazorpayAccountWebhookPayload::from([ + 'account' => $payloadData['account']['entity'], + ]), default => throw new InvalidArgumentException("Unknown event: {$event}"), }; diff --git a/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayAccountStatusHandler.php b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayAccountStatusHandler.php new file mode 100644 index 0000000000..76a7b2b76c --- /dev/null +++ b/backend/app/Services/Domain/Payment/Razorpay/EventHandlers/RazorpayAccountStatusHandler.php @@ -0,0 +1,80 @@ +account; + + $razorpayAccountId = $accountEntity->id; + + $platform = $this->accountRazorpayPlatformRepository->findByRazorpayAccountId($razorpayAccountId); + + if (!$platform) { + $this->logger->warning('Razorpay platform record not found for webhook', [ + 'event' => $event, + 'razorpay_account_id' => $razorpayAccountId, + ]); + return; + } + + $updateData = []; + + if (isset($accountEntity->status)) { + $updateData['status'] = $this->mapRazorpayStatusToLocalStatus($accountEntity->status); + } + + if ($accountEntity->status === 'activated') { + $updateData['activated_at'] = now()->toDateTimeString(); + } + + // Add error details if rejected + if ($accountEntity->status === 'rejected') { + $reasons = $accountEntity->reject_reasons ?? $accountEntity->rejection_reasons ?? []; + $updateData['error'] = json_encode(['reasons' => $reasons]); + } + + // Always update the raw details + $currentDetails = $platform->getRazorpayAccountDetails() ?? []; + if (is_string($currentDetails)) { + $currentDetails = json_decode($currentDetails, true) ?: []; + } + + $mergedDetails = array_merge($currentDetails, $accountEntity->toArray()); + $updateData['razorpay_account_details'] = json_encode($mergedDetails); + + $this->accountRazorpayPlatformRepository->updateById($platform->getId(), $updateData); + + $this->logger->info('Razorpay account webhook processed', [ + 'event' => $event, + 'razorpay_account_id' => $razorpayAccountId, + 'local_account_id' => $platform->getAccountId(), + 'new_status' => $updateData['status'] ?? null, + ]); + } + + private function mapRazorpayStatusToLocalStatus(string $razorpayStatus): string + { + return match ($razorpayStatus) { + 'activated' => 'active', + 'needs_clarification' => 'failed', + 'rejected' => 'failed', + 'under_review' => 'pending_activation', + 'created' => 'pending_activation', + default => $razorpayStatus, + }; + } +} diff --git a/backend/database/migrations/2026_05_11_090437_add_onboarding_data_to_account_razorpay_platforms.php b/backend/database/migrations/2026_05_11_090437_add_onboarding_data_to_account_razorpay_platforms.php new file mode 100644 index 0000000000..7022af3d71 --- /dev/null +++ b/backend/database/migrations/2026_05_11_090437_add_onboarding_data_to_account_razorpay_platforms.php @@ -0,0 +1,28 @@ +json('onboarding_data')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('account_razorpay_platforms', function (Blueprint $table) { + $table->dropColumn('onboarding_data'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 3cff35df8e..44e3b22992 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -4,6 +4,9 @@ use HiEvents\Http\Actions\Accounts\GetAccountAction; use HiEvents\Http\Actions\Accounts\Razorpay\CreateRazorpayLinkedAccountAction; use HiEvents\Http\Actions\Accounts\Razorpay\GetRazorpayLinkedAccountsAction; +use HiEvents\Http\Actions\Accounts\Razorpay\UpdateRazorpayBusinessStageAction; +use HiEvents\Http\Actions\Accounts\Razorpay\UpdateRazorpayStakeholderStageAction; +use HiEvents\Http\Actions\Accounts\Razorpay\UpdateRazorpaySettlementStageAction; use HiEvents\Http\Actions\Accounts\Stripe\CreateStripeConnectAccountAction; use HiEvents\Http\Actions\Accounts\Stripe\GetStripeConnectAccountsAction; use HiEvents\Http\Actions\Accounts\UpdateAccountAction; @@ -276,6 +279,9 @@ function (Router $router): void { $router->prefix('accounts/{accountId}/razorpay')->group(function (Router $router) { $router->get('/accounts', GetRazorpayLinkedAccountsAction::class); $router->post('/linked-account', CreateRazorpayLinkedAccountAction::class); + $router->put('/onboarding/business', UpdateRazorpayBusinessStageAction::class); + $router->put('/onboarding/stakeholder', UpdateRazorpayStakeholderStageAction::class); + $router->put('/onboarding/settlement', UpdateRazorpaySettlementStageAction::class); }); // VAT Settings diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandlerTest.php index 851324dd61..8b6ff4fcfc 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Razorpay/RazorpayWebhookHandlerTest.php @@ -46,6 +46,7 @@ protected function setUp(): void $this->refundHandlerMock, $this->paymentFailedHandlerMock, $this->paymentAuthorizedHandlerMock, + $this->createMock(\HiEvents\Services\Domain\Payment\Razorpay\EventHandlers\RazorpayAccountStatusHandler::class), $this->verificationServiceMock, $this->loggerMock, $this->cacheMock diff --git a/frontend/src/api/account.client.ts b/frontend/src/api/account.client.ts index 0d023d0a84..5aeec11753 100644 --- a/frontend/src/api/account.client.ts +++ b/frontend/src/api/account.client.ts @@ -1,5 +1,5 @@ import {api} from "./client.ts"; -import {Account, GenericDataResponse, IdParam, User, StripeConnectAccountsResponse, RazorpayAccountsResponse, CreateRazorpayLinkedAccountDTO, CreateRazorpayLinkedAccountResponse} from "../types.ts"; +import {Account, GenericDataResponse, IdParam, User, StripeConnectAccountsResponse, RazorpayAccountsResponse, CreateRazorpayLinkedAccountDTO, CreateRazorpayLinkedAccountResponse, UpdateRazorpayBusinessStageDTO, UpdateRazorpayStakeholderStageDTO, UpdateRazorpaySettlementStageDTO} from "../types.ts"; interface CreateAccountRequest { first_name: string; @@ -44,4 +44,25 @@ export const accountClient = { ); return response.data; }, + updateRazorpayBusiness: async (accountId: IdParam, payload: UpdateRazorpayBusinessStageDTO) => { + const response = await api.put>( + `accounts/${accountId}/razorpay/onboarding/business`, + payload + ); + return response.data; + }, + updateRazorpayStakeholder: async (accountId: IdParam, payload: UpdateRazorpayStakeholderStageDTO) => { + const response = await api.put>( + `accounts/${accountId}/razorpay/onboarding/stakeholder`, + payload + ); + return response.data; + }, + updateRazorpaySettlement: async (accountId: IdParam, payload: UpdateRazorpaySettlementStageDTO) => { + const response = await api.put>( + `accounts/${accountId}/razorpay/onboarding/settlement`, + payload + ); + return response.data; + }, } \ No newline at end of file diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingModal.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingModal.tsx index 7efd59d314..671d45fa91 100644 --- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingModal.tsx +++ b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingModal.tsx @@ -1,63 +1,82 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Modal, Stepper, Button, Group, TextInput, Select, Grid, Text } from '@mantine/core'; import { useForm } from '@mantine/form'; import { t } from '@lingui/macro'; -import { CreateRazorpayLinkedAccountDTO } from '../../../../../../../types'; +import { UpdateRazorpayBusinessStageDTO, UpdateRazorpayStakeholderStageDTO, UpdateRazorpaySettlementStageDTO, RazorpayAccount } from '../../../../../../../types'; +import { useUpdateRazorpayBusiness } from '../../../../../../../mutations/useUpdateRazorpayBusiness'; +import { useUpdateRazorpayStakeholder } from '../../../../../../../mutations/useUpdateRazorpayStakeholder'; +import { useUpdateRazorpaySettlement } from '../../../../../../../mutations/useUpdateRazorpaySettlement'; interface RazorpayOnboardingModalProps { accountId: number; opened: boolean; onClose: () => void; - onSubmit: (dto: CreateRazorpayLinkedAccountDTO) => void; - isSubmitting: boolean; + onSuccess: () => void; initialEmail?: string; initialLegalBusinessName?: string; + accountData?: RazorpayAccount; } export const RazorpayOnboardingModal = ({ accountId, opened, onClose, - onSubmit, - isSubmitting, + onSuccess, initialEmail = '', initialLegalBusinessName = '', + accountData, }: RazorpayOnboardingModalProps) => { const [activeStep, setActiveStep] = useState(0); const [error, setError] = useState(null); + const businessMutation = useUpdateRazorpayBusiness(); + const stakeholderMutation = useUpdateRazorpayStakeholder(); + const settlementMutation = useUpdateRazorpaySettlement(); + + useEffect(() => { + if (accountData?.onboarding_data && opened) { + if (accountData.onboarding_data.settlement) { + setActiveStep(3); + } else if (accountData.onboarding_data.stakeholder) { + setActiveStep(2); + } else if (accountData.onboarding_data.business) { + setActiveStep(1); + } + } + }, [opened]); // Only set the active step when the modal is opened + const form = useForm({ initialValues: { - email: initialEmail, - phone: '', - legalBusinessName: initialLegalBusinessName, - businessType: 'partnership', - contactName: '', - - regStreet1: '', - regStreet2: '', - regCity: '', - regState: '', - regPostalCode: '', - regCountry: 'IN', - - pan: '', - gst: '', - - stakeName: '', - stakeEmail: '', - stakePan: '', - stakeStreet: '', - stakeCity: '', - stakeState: '', - stakePostalCode: '', - stakeCountry: 'IN', - - accountNumber: '', - ifscCode: '', - beneficiaryName: '', + email: accountData?.onboarding_data?.business?.email || initialEmail, + phone: accountData?.onboarding_data?.business?.phone || '', + legalBusinessName: accountData?.onboarding_data?.business?.legalBusinessName || initialLegalBusinessName, + businessType: accountData?.onboarding_data?.business?.businessType || 'partnership', + contactName: accountData?.onboarding_data?.business?.contactName || '', + + regStreet1: accountData?.onboarding_data?.business?.registeredAddress?.street1 || '', + regStreet2: accountData?.onboarding_data?.business?.registeredAddress?.street2 || '', + regCity: accountData?.onboarding_data?.business?.registeredAddress?.city || '', + regState: accountData?.onboarding_data?.business?.registeredAddress?.state || '', + regPostalCode: accountData?.onboarding_data?.business?.registeredAddress?.postalCode || '', + regCountry: accountData?.onboarding_data?.business?.registeredAddress?.country || 'IN', + + pan: accountData?.onboarding_data?.business?.pan || '', + gst: accountData?.onboarding_data?.business?.gst || '', + + stakeName: accountData?.onboarding_data?.stakeholder?.name || '', + stakeEmail: accountData?.onboarding_data?.stakeholder?.email || '', + stakePan: accountData?.onboarding_data?.stakeholder?.pan || '', + stakeStreet: accountData?.onboarding_data?.stakeholder?.residentialAddress?.street || '', + stakeCity: accountData?.onboarding_data?.stakeholder?.residentialAddress?.city || '', + stakeState: accountData?.onboarding_data?.stakeholder?.residentialAddress?.state || '', + stakePostalCode: accountData?.onboarding_data?.stakeholder?.residentialAddress?.postalCode || '', + stakeCountry: accountData?.onboarding_data?.stakeholder?.residentialAddress?.country || 'IN', + + accountNumber: accountData?.onboarding_data?.settlement?.accountNumber || '', + ifscCode: accountData?.onboarding_data?.settlement?.ifscCode || '', + beneficiaryName: accountData?.onboarding_data?.settlement?.beneficiaryName || '', }, validate: { @@ -93,8 +112,8 @@ export const RazorpayOnboardingModal = ({ }); const stepFields = [ - ['legalBusinessName', 'email', 'phone', 'regStreet1', 'regCity', 'regState', 'regPostalCode'], - ['stakeName', 'stakeEmail', 'stakeStreet', 'stakeCity', 'stakeState', 'stakePostalCode'], + ['legalBusinessName', 'email', 'phone', 'regStreet1', 'regCity', 'regState', 'regPostalCode', 'pan'], + ['stakeName', 'stakeEmail', 'stakeStreet', 'stakeCity', 'stakeState', 'stakePostalCode', 'stakePan'], ['accountNumber', 'ifscCode', 'beneficiaryName'], ]; @@ -103,62 +122,74 @@ export const RazorpayOnboardingModal = ({ return stepFields[activeStep].some((f) => errors[f]); }; - const handleNext = () => { - if (!validateStep()) { - setActiveStep((s) => Math.min(s + 1, 3)); - } - }; - - const handleBack = () => setActiveStep((s) => Math.max(s - 1, 0)); - - const buildDto = (): CreateRazorpayLinkedAccountDTO => ({ - accountId, - email: form.values.email, - phone: form.values.phone, - legalBusinessName: form.values.legalBusinessName, - businessType: form.values.businessType, - contactName: form.values.contactName, - registeredAddress: { - street1: form.values.regStreet1, - street2: form.values.regStreet2, - city: form.values.regCity, - state: form.values.regState, - postalCode: form.values.regPostalCode, - country: form.values.regCountry, - }, - pan: form.values.pan, - gst: form.values.gst, - stakeholder: { - name: form.values.stakeName, - email: form.values.stakeEmail, - pan: form.values.stakePan, - residentialAddress: { - street: form.values.stakeStreet, - city: form.values.stakeCity, - state: form.values.stakeState, - postalCode: form.values.stakePostalCode, - country: form.values.stakeCountry, - }, - }, - settlement: { - accountNumber: form.values.accountNumber, - ifscCode: form.values.ifscCode, - beneficiaryName: form.values.beneficiaryName, - }, - }); + const handleNext = async () => { + if (validateStep()) return; - const handleSubmit = async () => { setError(null); - if (form.validate().hasErrors) return; - try { - await onSubmit(buildDto()); + if (activeStep === 0) { + const dto: UpdateRazorpayBusinessStageDTO = { + accountId, + email: form.values.email, + phone: form.values.phone, + legalBusinessName: form.values.legalBusinessName, + businessType: form.values.businessType, + contactName: form.values.contactName, + registeredAddress: { + street1: form.values.regStreet1, + street2: form.values.regStreet2, + city: form.values.regCity, + state: form.values.regState, + postalCode: form.values.regPostalCode, + country: form.values.regCountry, + }, + pan: form.values.pan, + gst: form.values.gst, + }; + await businessMutation.mutateAsync(dto); + setActiveStep(1); + } else if (activeStep === 1) { + const dto: UpdateRazorpayStakeholderStageDTO = { + accountId, + stakeholder: { + name: form.values.stakeName, + email: form.values.stakeEmail, + pan: form.values.stakePan, + residentialAddress: { + street: form.values.stakeStreet, + city: form.values.stakeCity, + state: form.values.stakeState, + postalCode: form.values.stakePostalCode, + country: form.values.stakeCountry, + }, + }, + }; + await stakeholderMutation.mutateAsync(dto); + setActiveStep(2); + } else if (activeStep === 2) { + const dto: UpdateRazorpaySettlementStageDTO = { + accountId, + settlement: { + accountNumber: form.values.accountNumber, + ifscCode: form.values.ifscCode, + beneficiaryName: form.values.beneficiaryName, + }, + }; + await settlementMutation.mutateAsync(dto); + setActiveStep(3); + } else if (activeStep === 3) { + onSuccess(); + } } catch (e: any) { - setError(e?.message || t`Something went wrong`); + setError(e?.response?.data?.message || e?.message || t`Something went wrong`); } }; + const handleBack = () => setActiveStep((s) => Math.max(s - 1, 0)); + + const isSubmitting = businessMutation.isPending || stakeholderMutation.isPending || settlementMutation.isPending; + return ( @@ -209,7 +240,7 @@ export const RazorpayOnboardingModal = ({ - + @@ -220,7 +251,7 @@ export const RazorpayOnboardingModal = ({ - + {t`Residential Address`} @@ -242,9 +273,10 @@ export const RazorpayOnboardingModal = ({ - + + {t`Review & Submit`} + {t`Please confirm all your details are correct before submitting.`} + @@ -262,11 +294,17 @@ export const RazorpayOnboardingModal = ({ {activeStep === 2 && ( + )} + + {activeStep === 3 && ( + )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingPanel.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingPanel.tsx index 42e04a4c84..d0e7108c98 100644 --- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingPanel.tsx +++ b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/OnboardingPanel.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'; import { Title, LoadingOverlay } from '@mantine/core'; import { t } from '@lingui/macro'; import { useGetRazorpayAccounts } from '../../../../../../../queries/useGetRazorpayAccounts'; -import { useCreateRazorpayLinkedAccount } from '../../../../../../../mutations/useCreateRazorpayLinkedAccount'; import { Account, RazorpayStatus } from '../../../../../../../types'; import { RazorpayOnboardingView } from './RazorpayOnboardingView'; import { RazorpayOnboardingModal } from './OnboardingModal'; @@ -25,7 +24,6 @@ const isValidStatus = (s: any): s is RazorpayStatus => export const RazorpayPanel: React.FC = ({ account }) => { const { data, isLoading, refetch } = useGetRazorpayAccounts(account.id); - const createMutation = useCreateRazorpayLinkedAccount(); const [showModal, setShowModal] = useState(false); const hasTracked = useRef(false); @@ -59,20 +57,12 @@ export const RazorpayPanel: React.FC = ({ account }) => { email: apiAccount.email ?? account.email, legal_business_name: apiAccount.legal_business_name ?? account.name, + onboarding_data: apiAccount.onboarding_data, } : undefined; const handleStart = () => setShowModal(true); - const handleSubmit = (dto: any) => { - createMutation.mutate(dto, { - onSuccess: () => { - setShowModal(false); - refetch(); - }, - }); - }; - return ( <> @@ -91,10 +81,13 @@ export const RazorpayPanel: React.FC<Props> = ({ account }) => { accountId={Number(account.id)} opened={showModal} onClose={() => setShowModal(false)} - onSubmit={handleSubmit} - isSubmitting={createMutation.isPending} + onSuccess={() => { + setShowModal(false); + refetch(); + }} initialEmail={account.email} initialLegalBusinessName={account.name} + accountData={initialData} /> </> ); diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/states/RetryFormState.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/states/RetryFormState.tsx index 3755fda479..b132299749 100644 --- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/states/RetryFormState.tsx +++ b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/RazorpayOnboarding/states/RetryFormState.tsx @@ -13,10 +13,10 @@ export const RetryFormState = ({ accountId={accountId} opened={true} onClose={() => {}} - onSubmit={() => {}} - isSubmitting={false} - initialEmail={initialData?.email} - initialLegalBusinessName={initialData?.legal_business_name} + onSuccess={() => {}} + initialEmail={initialData?.email || ''} + initialLegalBusinessName={initialData?.legal_business_name || ''} + accountData={initialData} /> ); }; \ No newline at end of file diff --git a/frontend/src/mutations/useUpdateRazorpayBusiness.ts b/frontend/src/mutations/useUpdateRazorpayBusiness.ts new file mode 100644 index 0000000000..4c6f5f1396 --- /dev/null +++ b/frontend/src/mutations/useUpdateRazorpayBusiness.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query'; +import { accountClient } from '../api/account.client'; +import { UpdateRazorpayBusinessStageDTO, CreateRazorpayLinkedAccountResponse } from '../types'; +import { AxiosError } from 'axios'; + +export const useUpdateRazorpayBusiness = () => { + return useMutation<CreateRazorpayLinkedAccountResponse, AxiosError, UpdateRazorpayBusinessStageDTO>({ + mutationFn: async (payload) => { + const { data } = await accountClient.updateRazorpayBusiness(payload.accountId, payload); + return data; + }, + }); +}; diff --git a/frontend/src/mutations/useUpdateRazorpaySettlement.ts b/frontend/src/mutations/useUpdateRazorpaySettlement.ts new file mode 100644 index 0000000000..f07da6ad8f --- /dev/null +++ b/frontend/src/mutations/useUpdateRazorpaySettlement.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query'; +import { accountClient } from '../api/account.client'; +import { UpdateRazorpaySettlementStageDTO, CreateRazorpayLinkedAccountResponse } from '../types'; +import { AxiosError } from 'axios'; + +export const useUpdateRazorpaySettlement = () => { + return useMutation<CreateRazorpayLinkedAccountResponse, AxiosError, UpdateRazorpaySettlementStageDTO>({ + mutationFn: async (payload) => { + const { data } = await accountClient.updateRazorpaySettlement(payload.accountId, payload); + return data; + }, + }); +}; diff --git a/frontend/src/mutations/useUpdateRazorpayStakeholder.ts b/frontend/src/mutations/useUpdateRazorpayStakeholder.ts new file mode 100644 index 0000000000..5ed2bfd651 --- /dev/null +++ b/frontend/src/mutations/useUpdateRazorpayStakeholder.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query'; +import { accountClient } from '../api/account.client'; +import { UpdateRazorpayStakeholderStageDTO, CreateRazorpayLinkedAccountResponse } from '../types'; +import { AxiosError } from 'axios'; + +export const useUpdateRazorpayStakeholder = () => { + return useMutation<CreateRazorpayLinkedAccountResponse, AxiosError, UpdateRazorpayStakeholderStageDTO>({ + mutationFn: async (payload) => { + const { data } = await accountClient.updateRazorpayStakeholder(payload.accountId, payload); + return data; + }, + }); +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1d5a367499..f53d5e14af 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1098,6 +1098,11 @@ export interface RazorpayAccount { razorpay_account_id?: string | null; email?: string; legal_business_name?: string; + onboarding_data?: { + business?: Partial<UpdateRazorpayBusinessStageDTO>; + stakeholder?: Partial<UpdateRazorpayStakeholderStageDTO['stakeholder']>; + settlement?: Partial<UpdateRazorpaySettlementStageDTO['settlement']>; + } | null; } export interface RazorpayViewProps { @@ -1152,6 +1157,52 @@ export interface CreateRazorpayLinkedAccountDTO { }; } +export interface UpdateRazorpayBusinessStageDTO { + accountId: number; + email: string; + phone: string; + legalBusinessName: string; + businessType: string; + contactName: string; + profileCategory?: string; + profileSubcategory?: string; + registeredAddress: { + street1: string; + street2?: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + pan: string; + gst?: string; +} + +export interface UpdateRazorpayStakeholderStageDTO { + accountId: number; + stakeholder: { + name: string; + email: string; + pan?: string; + residentialAddress: { + street: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + }; +} + +export interface UpdateRazorpaySettlementStageDTO { + accountId: number; + settlement: { + accountNumber: string; + ifscCode: string; + beneficiaryName: string; + }; +} + export interface CreateRazorpayLinkedAccountResponse { linkedAccountId: number; razorpayAccountId: string;