From 50cf966da7e5d3b2d1cd585b71b3c80d726bd18f Mon Sep 17 00:00:00 2001 From: nurafrinaalimi16 Date: Thu, 17 Sep 2026 15:08:43 +0800 Subject: [PATCH] feature/shoraka-integration --- app/Http/Controllers/ShorakaController.php | 303 ++++++++++++++++++ app/Services/ShorakaService.php | 138 ++++++++ app/ShorakaTransaction.php | 39 +++ bootstrap/app.php | 1 + config/services.php | 21 ++ ...4818_create_shoraka_transactions_table.php | 87 +++++ routes/api.php | 0 routes/web.php | 3 + 8 files changed, 592 insertions(+) create mode 100644 app/Http/Controllers/ShorakaController.php create mode 100644 app/Services/ShorakaService.php create mode 100644 app/ShorakaTransaction.php create mode 100644 config/services.php create mode 100644 database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php create mode 100644 routes/api.php diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php new file mode 100644 index 0000000..003e185 --- /dev/null +++ b/app/Http/Controllers/ShorakaController.php @@ -0,0 +1,303 @@ +validate($request, [ + 'norujukan' => + 'required|string', + + 'ownership' => + 'required|string', + + 'order_amount' => + 'required|numeric|min:0.01', + + 'murabaha_amount' => + 'required|numeric|min:0.01', + + 'tenor_other' => + 'required|integer|min:1', + ]); + + /* + * Jangan hubungi Shoraka selagi credentials + * belum diterima atau belum dimasukkan. + */ + if ( + empty(config('services.shoraka.api_id')) || + empty(config('services.shoraka.secret_key')) + ) { + return response()->json([ + 'success' => false, + 'message' => + 'API ID dan Secret Key Shoraka belum dikonfigurasi.', + ], 503); + } + + /* + * Semak sama ada nombor rujukan yang sama + * sudah pernah berjaya dihantar. + */ + $existing = ShorakaTransaction::where( + 'norujukan', + $validated['norujukan'] + )->first(); + + if ( + $existing && + !empty($existing->order_id) + ) { + return response()->json([ + 'success' => false, + 'message' => + 'Transaksi ini telah dihantar ke Shoraka sebelum ini.', + 'data' => [ + 'norujukan' => + $existing->norujukan, + + 'order_id' => + $existing->order_id, + + 'status' => + $existing->status, + ], + ], 409); + } + + /* + * Sediakan data mengikut format Shoraka. + */ + $payload = [ + 'product_type' => + 'FINANCING', + + /* + * Kod komoditi perlu disahkan + * dengan pihak Shoraka. + */ + 'commodity_type' => + '000-COPPER', + + /* + * Nama pelanggan. + */ + 'ownership' => + $validated['ownership'], + + /* + * Nombor rujukan pembiayaan eRahn. + */ + 'ownership_doc_no' => + $validated['norujukan'], + + /* + * Format yang diminta Shoraka: + * DD/MM/YYYY + */ + 'value_date' => + Carbon::now()->format('d/m/Y'), + + 'order_currency' => + 'MYR', + + 'order_amount' => + $validated['order_amount'], + + 'murabaha_amount' => + $validated['murabaha_amount'], + + /* + * Sila sahkan dengan Shoraka sama ada + * nilai sebenar ialah OTHER atau OTHERS. + */ + 'tenor' => + 'OTHERS', + + 'tenor_other' => + $validated['tenor_other'], + + 'tenor_other_unit' => + 'MONTH', + + 'order_type' => + 'Buy & Sell', + ]; + + /* + * Simpan rekod awal sebelum request dihantar. + */ + $transaction = ShorakaTransaction::updateOrCreate( + [ + 'norujukan' => + $validated['norujukan'], + ], + [ + 'status' => + 'PENDING', + + 'product_type' => + $payload['product_type'], + + 'commodity_type' => + $payload['commodity_type'], + + 'ownership_name' => + $payload['ownership'], + + 'ownership_doc_no' => + $payload['ownership_doc_no'], + + 'order_amount' => + $payload['order_amount'], + + 'murabaha_amount' => + $payload['murabaha_amount'], + + 'request_payload' => + $payload, + + 'error_message' => + null, + ] + ); + + try { + /* + * Tambah bilangan percubaan request. + */ + $transaction->increment( + 'attempt_count' + ); + + /* + * Hantar order kepada API Shoraka. + */ + $response = $shoraka->submitOrder( + $payload + ); + + /* + * Shoraka mesti memberikan orderId. + */ + if (empty($response['orderId'])) { + throw new RuntimeException( + 'Shoraka tidak memberikan orderId.' + ); + } + + $status = isset($response['status']) + ? $response['status'] + : 'SUBMITTED'; + + /* + * Simpan respons Shoraka. + */ + $transaction->update([ + 'order_id' => + $response['orderId'], + + 'status' => + $status, + + 'certificate_url' => + isset($response['certificateUrl']) + ? $response['certificateUrl'] + : null, + + 'response_payload' => + $response, + + 'submitted_at' => + Carbon::now(), + + 'completed_at' => + $status === 'Completed' + ? Carbon::now() + : null, + + 'error_message' => + null, + ]); + + return response()->json([ + 'success' => true, + + 'message' => + 'Order berjaya dihantar ke Shoraka.', + + 'data' => [ + 'norujukan' => + $transaction->norujukan, + + 'order_id' => + $transaction->order_id, + + 'status' => + $transaction->status, + + 'certificate_url' => + $transaction->certificate_url, + ], + ], 200); + } catch (Throwable $exception) { + /* + * Simpan status gagal. + */ + $transaction->update([ + 'status' => + 'FAILED', + + 'error_message' => + $exception->getMessage(), + ]); + + /* + * Rekodkan ralat tanpa API Secret Key. + */ + Log::error( + 'Shoraka order submission failed', + [ + 'norujukan' => + $validated['norujukan'], + + 'message' => + $exception->getMessage(), + ] + ); + + return response()->json([ + 'success' => false, + + 'message' => + 'Order gagal dihantar ke Shoraka.', + + /* + * Paparkan error untuk UAT sahaja. + * Buang field ini semasa production. + */ + 'error' => + $exception->getMessage(), + ], 502); + } + } +} \ No newline at end of file diff --git a/app/Services/ShorakaService.php b/app/Services/ShorakaService.php new file mode 100644 index 0000000..a00c19c --- /dev/null +++ b/app/Services/ShorakaService.php @@ -0,0 +1,138 @@ +baseUrl = rtrim( + config('services.shoraka.base_url'), + '/' + ); + + $this->apiId = + config('services.shoraka.api_id'); + + $this->secretKey = + config('services.shoraka.secret_key'); + + $this->timeout = + config('services.shoraka.timeout', 60); + } + + public function submitOrder(array $data): array + { + $payload = [ + 'api_id' => $this->apiId, + 'product_type' => + $data['product_type'], + 'commodity_type' => + $data['commodity_type'], + 'ownership' => + $data['ownership'], + 'value_date' => + $data['value_date'], + 'order_currency' => + $data['order_currency'], + 'order_amount' => + $this->formatAmount( + $data['order_amount'] + ), + 'murabaha_amount' => + $this->formatAmount( + $data['murabaha_amount'] + ), + 'tenor' => + $data['tenor'], + 'tenor_other' => + $data['tenor_other'] ?? '', + 'tenor_other_unit' => + $data['tenor_other_unit'] ?? '', + 'order_type' => + $data['order_type'], + 'ownership_doc_no' => + $data['ownership_doc_no'] ?? '', + ]; + + $payload['signature'] = + $this->makeSubmitSignature($payload); + + $response = Http::asForm() + ->acceptJson() + ->timeout($this->timeout) + ->post( + $this->baseUrl . '/api/submitorder', + $payload + ); + + return $this->getJsonResponse($response); + } + + protected function makeSubmitSignature( + array $payload + ): string { + $values = [ + $this->secretKey, + $this->apiId, + $payload['product_type'], + $payload['commodity_type'], + $payload['ownership'], + $payload['value_date'], + $payload['order_currency'], + $payload['order_amount'], + $payload['murabaha_amount'], + $payload['tenor'], + $payload['tenor_other'], + $payload['tenor_other_unit'], + $payload['order_type'], + ]; + + return hash( + 'sha256', + implode(';', $values) + ); + } + + protected function formatAmount($amount): string + { + return number_format( + (float) $amount, + 2, + '.', + '' + ); + } + + protected function getJsonResponse( + Response $response + ): array { + if (!$response->successful()) { + throw new RuntimeException( + 'Shoraka request failed. HTTP ' . + $response->status() . + ': ' . + $response->body() + ); + } + + $data = $response->json(); + + if (!is_array($data)) { + throw new RuntimeException( + 'Shoraka returned an invalid response.' + ); + } + + return $data; + } +} \ No newline at end of file diff --git a/app/ShorakaTransaction.php b/app/ShorakaTransaction.php new file mode 100644 index 0000000..f73dbb5 --- /dev/null +++ b/app/ShorakaTransaction.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'murabaha_amount' => 'decimal:2', + 'request_payload' => 'array', + 'response_payload' => 'array', + 'callback_payload' => 'array', + 'submitted_at' => 'datetime', + 'completed_at' => 'datetime', + ]; +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index e5febcc..9e4fe1c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -87,6 +87,7 @@ $app->singleton( */ $app->configure('app'); +$app->configure('services'); $app->configure('logging'); $app->configure('tinker'); $app->configure('tennant_database'); diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..cabce3f --- /dev/null +++ b/config/services.php @@ -0,0 +1,21 @@ + [ + 'base_url' => env( + 'SHORAKA_BASE_URL', + 'https://uat2.shorakagroup.com' + ), + + 'api_id' => env('SHORAKA_API_ID'), + + 'secret_key' => env('SHORAKA_SECRET_KEY'), + + 'timeout' => env( + 'SHORAKA_TIMEOUT', + 60 + ), + ], + +]; \ No newline at end of file diff --git a/database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php b/database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php new file mode 100644 index 0000000..1d4919a --- /dev/null +++ b/database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php @@ -0,0 +1,87 @@ +id(); + + // Rujukan pembiayaan dalam eRahn + $table->string('norujukan')->unique(); + + // Maklumat daripada Shoraka + $table->string('order_id') + ->nullable() + ->unique(); + + $table->string('status') + ->default('PENDING'); + + $table->string('product_type') + ->default('FINANCING'); + + $table->string('commodity_type'); + + $table->string('ownership_name'); + $table->string('ownership_doc_no') + ->nullable(); + + $table->decimal( + 'order_amount', + 15, + 2 + ); + + $table->decimal( + 'murabaha_amount', + 15, + 2 + ); + + $table->string('certificate_url') + ->nullable(); + + $table->string('certificate_path') + ->nullable(); + + // Simpan payload untuk audit dan troubleshooting + $table->json('request_payload') + ->nullable(); + + $table->json('response_payload') + ->nullable(); + + $table->json('callback_payload') + ->nullable(); + + $table->unsignedInteger('attempt_count') + ->default(0); + + $table->text('error_message') + ->nullable(); + + $table->timestamp('submitted_at') + ->nullable(); + + $table->timestamp('completed_at') + ->nullable(); + + $table->timestamps(); + + $table->index('status'); + } + ); + } + + public function down() + { + Schema::dropIfExists('shoraka_transactions'); + } +} diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..e69de29 diff --git a/routes/web.php b/routes/web.php index a08e973..2dd6886 100644 --- a/routes/web.php +++ b/routes/web.php @@ -116,6 +116,9 @@ $router->group(['prefix'=>'api'], function () use ($router){ $router->get('gadai/list/rekod/old',['uses'=>'GadaiController@rekodGadaian']); $router->get('gadai/online/search/detail',['uses'=>'GadaiController@searchGadaiOnline']); + // Shoraka Commodity API + $router->post('shoraka/orders',['uses' => 'ShorakaController@submit']); + $router->get('check/serah/gadai',['uses'=>'GadaiController@checkSerahGadaian']); $router->get('check/serah/tebus',['uses'=>'TebusController@checkSerahTebus']); $router->get('check/serah/lelong',['uses'=>'LelongController@checkSerahLelong']);