From 50cf966da7e5d3b2d1cd585b71b3c80d726bd18f Mon Sep 17 00:00:00 2001 From: nurafrinaalimi16 Date: Thu, 17 Sep 2026 15:08:43 +0800 Subject: [PATCH 1/8] 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']); -- 2.52.0 From 6c9f2f4f0fd8e7bcc96d45afae060af425112836 Mon Sep 17 00:00:00 2001 From: nurafrinaalimi16 Date: Thu, 8 Oct 2026 12:30:02 +0800 Subject: [PATCH 2/8] feat(shoraka): integrate transactions with Shoraka API --- app/Http/Controllers/GadaiController.php | 16 + app/Http/Controllers/KomuditiController.php | 42 +- app/Http/Controllers/ShorakaController.php | 469 ++++++++---------- .../Controllers/ShorakaGadaiController.php | 64 +++ app/Services/ShorakaGadaiService.php | 73 +++ app/Services/ShorakaService.php | 246 +++++---- app/ShorakaTransaction.php | 1 + app/Support/v1/Penebusan.php | 39 +- app/Support/v2/Penebusan.php | 48 +- ...000_add_kodcaw_to_shoraka_transactions.php | 22 + docs/shoraka-integration.md | 147 ++++++ routes/web.php | 9 + tests/ShorakaGadaiTest.php | 124 +++++ tests/ShorakaIntegrationTest.php | 258 ++++++++++ 14 files changed, 1077 insertions(+), 481 deletions(-) create mode 100644 app/Http/Controllers/ShorakaGadaiController.php create mode 100644 app/Services/ShorakaGadaiService.php create mode 100644 database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php create mode 100644 docs/shoraka-integration.md create mode 100644 tests/ShorakaGadaiTest.php create mode 100644 tests/ShorakaIntegrationTest.php diff --git a/app/Http/Controllers/GadaiController.php b/app/Http/Controllers/GadaiController.php index 0c8006f..bba2619 100644 --- a/app/Http/Controllers/GadaiController.php +++ b/app/Http/Controllers/GadaiController.php @@ -229,6 +229,9 @@ class GadaiController extends Controller public function daftarGadaiMarhun($norujukan,Request $request){ + if (\App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) { + abort(409, 'Kontrak telah dihantar ke Shoraka dan tidak boleh diubah.'); + } // dd("ggwp"); $master = new MasterServices($request->kodcaw); $response = $master->createMarhunGadai($request->norujukan,$request); @@ -406,6 +409,11 @@ class GadaiController extends Controller } public function pembelianKomuditi($kodcaw,$norujukan){ + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists() + || !\App\KomuditiTransaksi::on('mysql7')->where('norujukan', $norujukan)->exists()) { + app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan); + } + $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); $gadai = Gadai::on($cawangan['mysql']) @@ -418,6 +426,10 @@ class GadaiController extends Controller } public function cetakSAG($kodcaw,$norujukan,Request $request){ + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { + app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan); + } + $harini = Carbon::now(); $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); @@ -442,6 +454,10 @@ class GadaiController extends Controller } public function serahMarhun($kodcaw,$norujukan){ + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { + app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan); + } + $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); $gadai = Gadai::on($cawangan['mysql']) ->where('norujukan','=',$norujukan) diff --git a/app/Http/Controllers/KomuditiController.php b/app/Http/Controllers/KomuditiController.php index ff512b6..4686504 100644 --- a/app/Http/Controllers/KomuditiController.php +++ b/app/Http/Controllers/KomuditiController.php @@ -117,6 +117,9 @@ class KomuditiController extends Controller } public function updateKomuditi(Request $request){ + if (\App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) { + abort(409, 'Transaksi Shoraka memerlukan semakan operasi; stok lama tidak boleh diubah.'); + } $current = Carbon::now(); $current = new Carbon(); @@ -138,37 +141,20 @@ class KomuditiController extends Controller return response()->json($komuditi_transaksi); } - public function transaksiKomuditi(Request $request){ - $current = Carbon::now(); - $current = new Carbon(); - - $komuditi_current = KomuditiPurchase::on('mysql7')->where('date_purchase','=',$current->toDateString())->orderBy('created_at','desc')->first(); - - $unit_current = $komuditi_current['unit_komuditi']; - $update_unit = $unit_current - $request->unit_komuditi; - - $unit_current = round((float) $komuditi_current['unit_komuditi'], 2); - $unit_komuditi = round((float) $request->unit_komuditi, 2); - $update_unit = round($unit_current - $unit_komuditi, 2); - - $update_komuditi = KomuditiPurchase::on('mysql7')->where('id','=',$komuditi_current['id'])->update([ - 'unit_komuditi' => $update_unit - ]); - - $komuditi_transaksi = KomuditiTransaksi::on('mysql7')->create([ - 'unit_komuditi' => $unit_komuditi, - 'nosijil'=>$komuditi_current['no_sijil'], - 'norujukan' => $request->norujukan, - 'kodcaw' => $request->kodcaw , - 'teller' => $request->teller , - 'masa' => $current->toTimeString(), - 'tarikh' => $current->toDateString() - ]); - - return response()->json($komuditi_transaksi); + public function transaksiKomuditi(Request $request) + { + $this->validate($request, ['kodcaw' => 'required|string', 'norujukan' => 'required|string']); + return app(ShorakaGadaiController::class)->submitGadai( + $request->kodcaw, $request->norujukan, + app(\App\Services\ShorakaGadaiService::class), + app(\App\Services\ShorakaService::class) + ); } public function batalKomuditi(Request $request){ + if (\App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) { + abort(409, 'Transaksi Shoraka memerlukan semakan operasi; stok lama tidak boleh diubah.'); + } $komuditi_current = KomuditiPurchase::on('mysql7')->orderBy('created_at','desc')->first(); diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php index 003e185..3b9ae02 100644 --- a/app/Http/Controllers/ShorakaController.php +++ b/app/Http/Controllers/ShorakaController.php @@ -5,299 +5,228 @@ namespace App\Http\Controllers; use App\ShorakaTransaction; use App\Services\ShorakaService; use Carbon\Carbon; +use Illuminate\Database\QueryException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; -use RuntimeException; use Throwable; class ShorakaController extends Controller { - /** - * Hantar transaksi pembiayaan pelanggan ke Shoraka. - */ - public function submit( - Request $request, - ShorakaService $shoraka - ) { - /* - * Validation untuk Lumen 7. - */ + public function submit(Request $request, ShorakaService $shoraka) + { $validated = $this->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', + 'norujukan' => 'required|string|max:255', + 'ownership' => 'required|string|max:255', + 'order_amount' => 'required|numeric|min:0.01|max:9999999999999.99', + 'murabaha_amount' => 'required|numeric|min:0.01|max:9999999999999.99', + '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); + if (!$shoraka->configured()) { + return $this->unconfigured(); } - /* - * 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); + $existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first(); + if ($existing) { + return $this->duplicate($existing); } - /* - * 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', + 'product_type' => 'FINANCING', + 'commodity_type' => '000-COPPER', + 'ownership' => $validated['ownership'], + 'ownership_doc_no' => $validated['norujukan'], + 'value_date' => Carbon::now()->format('d/m/Y'), + 'order_currency' => 'MYR', + 'order_amount' => number_format((float) $validated['order_amount'], 2, '.', ''), + 'murabaha_amount' => number_format((float) $validated['murabaha_amount'], 2, '.', ''), + // Appendix codes in v1.5: OTHERS and MONTH. + '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 { + // The unique norujukan index is the atomic claim for concurrent submissions. + $transaction = ShorakaTransaction::create([ + 'norujukan' => $validated['norujukan'], + 'kodcaw' => $request->attributes->get('shoraka_kodcaw'), + '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, + 'attempt_count' => 1, + ]); + } catch (QueryException $exception) { + $existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first(); + if ($existing) { + return $this->duplicate($existing); + } + throw $exception; + } 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, - ]); + $response = $shoraka->submitOrder($payload); + $this->saveOrder($transaction, $response); 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); + 'message' => 'Order berjaya dihantar ke Shoraka.', + 'data' => $this->orderData($transaction), + ]); } catch (Throwable $exception) { - /* - * Simpan status gagal. - */ + // A timeout or invalid response does not prove the remote trade failed. + // Block resubmission until this reference has been reconciled with Shoraka. $transaction->update([ - 'status' => - 'FAILED', - - 'error_message' => - $exception->getMessage(), + 'status' => $transaction->order_id ? $transaction->status : 'UNKNOWN', + 'error_message' => 'Submission outcome requires reconciliation with Shoraka.', ]); - /* - * 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); + return $this->upstreamError('submit', $transaction, $exception); } } -} \ No newline at end of file + + public function refresh(string $norujukan, ShorakaService $shoraka) + { + if (!$shoraka->configured()) { + return $this->unconfigured(); + } + $transaction = ShorakaTransaction::where('norujukan', $norujukan)->firstOrFail(); + if (!$transaction->order_id) { + return $this->missingOrderId(); + } + + try { + $response = $shoraka->orderStatus($transaction->order_id); + $this->saveOrder($transaction, $response); + + return response()->json(['success' => true, 'data' => $this->orderData($transaction)]); + } catch (Throwable $exception) { + return $this->upstreamError('status', $transaction, $exception); + } + } + + public function certificate(string $norujukan, ShorakaService $shoraka) + { + if (!$shoraka->configured()) { + return $this->unconfigured(); + } + $transaction = ShorakaTransaction::where('norujukan', $norujukan)->firstOrFail(); + if (!$transaction->order_id) { + return $this->missingOrderId(); + } + + try { + $pdf = $shoraka->certificate($transaction->order_id); + + return response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="shoraka-' . $transaction->id . '.pdf"', + 'Cache-Control' => 'private, no-store', + ]); + } catch (Throwable $exception) { + return $this->upstreamError('certificate', $transaction, $exception); + } + } + + public function callback(Request $request, ShorakaService $shoraka) + { + if (!$shoraka->configured()) { + return $this->unconfigured(); + } + $payload = $request->json()->all(); + if (!$shoraka->validCallback($payload)) { + return response()->json(['success' => false, 'message' => 'Invalid callback signature.'], 401); + } + + // ownershipDocNo and certificateUrl are NOT signed. Never use them to link + // a callback to a local record or download a file. + $transaction = ShorakaTransaction::where('order_id', $payload['orderId'])->first(); + if (!$transaction) { + return response()->json(['success' => false, 'message' => 'Order not found.'], 404); + } + + try { + // There is no signed callback timestamp. Query the current order so a + // delayed/replayed callback cannot directly restore an old status. + $response = $shoraka->orderStatus($transaction->order_id); + unset($payload['signature']); + $this->saveOrder($transaction, $response, ['callback_payload' => $payload]); + + // Exact acknowledgement required by section 6. + return response('OK', 200, ['Content-Type' => 'text/plain']); + } catch (Throwable $exception) { + return $this->upstreamError('callback', $transaction, $exception); + } + } + + protected function saveOrder(ShorakaTransaction $transaction, array $response, array $extra = []): void + { + $transaction->update(array_merge([ + 'order_id' => (string) $response['orderId'], + 'status' => $response['status'], + 'certificate_url' => $response['certificateUrl'] ?? null, + 'response_payload' => $response, + 'submitted_at' => $transaction->submitted_at ?: Carbon::now(), + 'completed_at' => $response['status'] === 'Completed' + ? ($transaction->completed_at ?: Carbon::now()) : $transaction->completed_at, + 'error_message' => null, + ], $extra)); + app(\App\Services\ShorakaGadaiService::class)->sync($transaction); + } + + protected function orderData(ShorakaTransaction $transaction): array + { + return [ + 'norujukan' => $transaction->norujukan, + 'kodcaw' => $transaction->kodcaw, + 'order_id' => $transaction->order_id, + 'status' => $transaction->status, + 'certificate_url' => $transaction->certificate_url, + ]; + } + + protected function duplicate(ShorakaTransaction $transaction) + { + return response()->json([ + 'success' => false, + 'message' => 'Transaksi telah direkodkan. Semak status dengan Shoraka sebelum penghantaran semula.', + 'data' => $this->orderData($transaction), + ], 409); + } + + protected function missingOrderId() + { + return response()->json([ + 'success' => false, + 'message' => 'Order ID belum tersedia. Semakan dengan Shoraka diperlukan.', + ], 409); + } + + protected function unconfigured() + { + return response()->json([ + 'success' => false, + 'message' => 'API ID dan Secret Key Shoraka belum dikonfigurasi.', + ], 503); + } + + protected function upstreamError(string $operation, ShorakaTransaction $transaction, Throwable $exception) + { + // Connection exceptions may include request details; log only the class. + Log::error('Shoraka request failed', [ + 'operation' => $operation, + 'transaction_id' => $transaction->id, + 'exception_type' => get_class($exception), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Permintaan Shoraka gagal. Semak status transaksi sebelum mencuba semula.', + ], 502); + } +} diff --git a/app/Http/Controllers/ShorakaGadaiController.php b/app/Http/Controllers/ShorakaGadaiController.php new file mode 100644 index 0000000..c25383b --- /dev/null +++ b/app/Http/Controllers/ShorakaGadaiController.php @@ -0,0 +1,64 @@ +json(['provider' => 'shoraka', 'ready' => $shoraka->configured()]); + } + + public function showGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService) + { + $gadai = $gadaiService->gadai($kodcaw, $norujukan); + $order = $gadaiService->transaction($kodcaw, $norujukan); + return response()->json([ + 'success' => true, + 'data' => [ + 'norujukan' => $norujukan, 'kodcaw' => $kodcaw, + 'order_amount' => $gadai->pinjaman, 'murabaha_amount' => $gadai->hargajualan, + 'order' => $order ? $this->orderData($order) : null, + 'details' => $order ? $order->response_payload : null, + 'can_complete' => $order && $order->status === 'Completed' + && number_format((float) $gadai->pinjaman, 2, '.', '') === $order->order_amount + && number_format((float) $gadai->hargajualan, 2, '.', '') === $order->murabaha_amount, + ], + ]); + } + + public function submitGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) + { + $gadai = $gadaiService->gadai($kodcaw, $norujukan); + $existing = $gadaiService->transaction($kodcaw, $norujukan); + if ($existing) { + return $this->duplicate($existing); + } + // Historical commodity purchases must never be traded again. + if (KomuditiTransaksi::on('mysql7')->where('norujukan', $norujukan)->exists() + || $gadai->cetak || $gadai->sttebus) { + abort(409, 'Gadaian lama atau telah selesai tidak boleh dihantar sebagai order baharu.'); + } + $request = new Request($gadaiService->payload($gadai)); + $request->attributes->set('shoraka_kodcaw', $kodcaw); + return $this->submit($request, $shoraka); + } + + public function refreshGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) + { + $gadaiService->gadai($kodcaw, $norujukan); + $gadaiService->transaction($kodcaw, $norujukan); + return $this->refresh($norujukan, $shoraka); + } + + public function certificateGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) + { + $gadaiService->assertCompleted($kodcaw, $norujukan); + return $this->certificate($norujukan, $shoraka); + } +} diff --git a/app/Services/ShorakaGadaiService.php b/app/Services/ShorakaGadaiService.php new file mode 100644 index 0000000..f091b3e --- /dev/null +++ b/app/Services/ShorakaGadaiService.php @@ -0,0 +1,73 @@ +where('kodcaw', $kodcaw)->firstOrFail(); + return Gadai::on($branch->mysql)->where('norujukan', $norujukan)->where('kodcaw', $kodcaw)->firstOrFail(); + } + + public function payload(Gadai $gadai): array + { + $customer = Customer::on('mysql7')->where(function ($query) use ($gadai) { + $query->where('kpbaru', $gadai->nokp)->orWhere('kplama', $gadai->nokp); + })->firstOrFail(); + if (!$customer->nama || $gadai->pinjaman <= 0 || $gadai->hargajualan < $gadai->pinjaman) { + throw ValidationException::withMessages([ + 'norujukan' => 'Nama pelanggan, pembiayaan dan harga jualan mesti lengkap.', + ]); + } + + return [ + 'norujukan' => $gadai->norujukan, + 'ownership' => $customer->nama, + 'order_amount' => $gadai->pinjaman, + 'murabaha_amount' => $gadai->hargajualan, + // Both GadaiV1 and GadaiV2 price the contract using upah12. + // "tempoh" is elapsed charge time, NOT the contractual tenor. + 'tenor_other' => 12, + ]; + } + + public function transaction(string $kodcaw, string $norujukan) + { + $order = ShorakaTransaction::where('norujukan', $norujukan)->first(); + if ($order && (string) $order->kodcaw !== $kodcaw) { + abort(409, 'Rekod Shoraka tidak dipadankan dengan cawangan ini.'); + } + return $order; + } + + public function sync(ShorakaTransaction $order): void + { + if (!$order->kodcaw) { + return; + } + $gadai = $this->gadai($order->kodcaw, $order->norujukan); + $sameAmounts = number_format((float) $gadai->pinjaman, 2, '.', '') === $order->order_amount + && number_format((float) $gadai->hargajualan, 2, '.', '') === $order->murabaha_amount; + $gadai->getConnection()->table('gadai')->where('norujukan', $order->norujukan)->update([ + 'tagkomuditi' => $order->status === 'Completed' && $sameAmounts ? 1 : 0, + ]); + } + + public function assertCompleted(string $kodcaw, string $norujukan): void + { + $gadai = $this->gadai($kodcaw, $norujukan); + $order = $this->transaction($kodcaw, $norujukan); + if (!$order || $order->status !== 'Completed' + || number_format((float) $gadai->pinjaman, 2, '.', '') !== $order->order_amount + || number_format((float) $gadai->hargajualan, 2, '.', '') !== $order->murabaha_amount) { + abort(409, 'Pembiayaan dan SAG ditahan sehingga transaksi Shoraka selesai dan amaun sepadan.'); + } + } +} diff --git a/app/Services/ShorakaService.php b/app/Services/ShorakaService.php index a00c19c..4e294f2 100644 --- a/app/Services/ShorakaService.php +++ b/app/Services/ShorakaService.php @@ -8,6 +8,15 @@ use RuntimeException; class ShorakaService { + const STATUSES = ['Active', 'Completed', 'Cancelled', 'Pending Sell', 'Take Delivery']; + + const CALLBACK_FIELDS = [ + 'orderId', 'status', 'bankName', 'ownershipName', 'commodityType', + 'unit', 'volume', 'productType', 'valueDate', 'cancelDate', 'orderType', + 'orderCurrency', 'orderAmount', 'murabahaAmount', 'tenor', + 'tenorOther', 'tenorOtherUnit', + ]; + protected $baseUrl; protected $apiId; protected $secretKey; @@ -15,124 +24,143 @@ class ShorakaService public function __construct() { - $this->baseUrl = rtrim( - config('services.shoraka.base_url'), - '/' - ); + $this->baseUrl = rtrim((string) config('services.shoraka.base_url'), '/'); + $this->apiId = (string) config('services.shoraka.api_id'); + $this->secretKey = (string) config('services.shoraka.secret_key'); + $this->timeout = (int) config('services.shoraka.timeout', 60); + } - $this->apiId = - config('services.shoraka.api_id'); - - $this->secretKey = - config('services.shoraka.secret_key'); - - $this->timeout = - config('services.shoraka.timeout', 60); + public function configured(): bool + { + return $this->apiId !== '' && $this->secretKey !== ''; } 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() - ); + $payload = ['api_id' => $this->apiId]; + foreach ([ + 'product_type', 'commodity_type', 'ownership', 'value_date', + 'order_currency', 'order_amount', 'murabaha_amount', 'tenor', + 'tenor_other', 'tenor_other_unit', 'order_type', 'ownership_doc_no', + ] as $field) { + $payload[$field] = $data[$field] ?? ''; + } + foreach (['order_amount', 'murabaha_amount'] as $field) { + $payload[$field] = number_format((float) $payload[$field], 2, '.', ''); } - $data = $response->json(); + // ownership_doc_no is deliberately excluded by the v1.5 signature contract. + $signed = $payload; + unset($signed['api_id'], $signed['ownership_doc_no']); + $payload['signature'] = $this->signature(array_values($signed)); - if (!is_array($data)) { - throw new RuntimeException( - 'Shoraka returned an invalid response.' - ); + return $this->orderResponse($this->request()->post( + $this->baseUrl . '/api/submitorder', $payload + )); + } + + public function orderStatus(string $orderId): array + { + return $this->orderResponse($this->request()->post( + $this->baseUrl . '/api/orderstatus', $this->orderPayload($orderId) + ), $orderId); + } + + public function certificate(string $orderId): string + { + // Section 5 specifies POST, despite GET in the document history. + $response = $this->request()->accept('application/pdf')->post( + $this->baseUrl . '/api/certificate', $this->orderPayload($orderId) + ); + $this->checkResponse($response); + $type = strtolower(trim(explode(';', $response->header('Content-Type'))[0])); + if ($type !== 'application/pdf' || substr($response->body(), 0, 5) !== '%PDF-') { + throw new RuntimeException('Shoraka returned an invalid PDF certificate.'); + } + + return $response->body(); + } + + public function validCallback(array $payload): bool + { + if (!$this->configured() + || !isset($payload['apiId'], $payload['signature']) + || !is_string($payload['apiId']) + || !hash_equals($this->apiId, $payload['apiId']) + || !is_string($payload['signature']) + || !preg_match('/\A[0-9a-fA-F]{64}\z/', $payload['signature'])) { + return false; + } + + $values = []; + foreach (self::CALLBACK_FIELDS as $field) { + // Preserve the original strings: decimal formatting affects the hash. + if (!array_key_exists($field, $payload) || !is_string($payload[$field])) { + return false; + } + $values[] = $payload[$field]; + } + + return $payload['orderId'] !== '' + && in_array($payload['status'], self::STATUSES, true) + && hash_equals($this->signature($values), strtolower($payload['signature'])); + } + + protected function signature(array $values): string + { + return hash('sha256', implode(';', array_merge( + [$this->secretKey, $this->apiId], $values + ))); + } + + protected function orderPayload(string $orderId): array + { + if ($orderId === '') { + throw new RuntimeException('Shoraka order ID is required.'); + } + + return [ + 'api_id' => $this->apiId, + 'order_id' => $orderId, + 'signature' => $this->signature([$orderId]), + ]; + } + + protected function request() + { + if (!$this->configured()) { + throw new RuntimeException('Shoraka credentials are not configured.'); + } + + // Never forward signed requests through redirects or retry a trade automatically. + return Http::asForm()->acceptJson()->timeout($this->timeout) + ->withOptions(['allow_redirects' => false]); + } + + protected function checkResponse(Response $response): void + { + if (!$response->successful()) { + // Do not expose upstream bodies which may contain credentials or customer data. + throw new RuntimeException('Shoraka request failed. HTTP ' . $response->status()); + } + } + + protected function orderResponse(Response $response, string $expectedId = null): array + { + $this->checkResponse($response); + $data = $response->json(); + if (!is_array($data) + || !isset($data['orderId'], $data['status']) + || !(is_string($data['orderId']) || is_int($data['orderId'])) + || (string) $data['orderId'] === '' + || !in_array($data['status'], self::STATUSES, true) + || ($expectedId !== null && (string) $data['orderId'] !== $expectedId)) { + throw new RuntimeException('Shoraka returned an invalid order response.'); + } + if (isset($data['certificateUrl']) && !is_string($data['certificateUrl'])) { + throw new RuntimeException('Shoraka returned an invalid certificate URL.'); } return $data; } -} \ No newline at end of file +} diff --git a/app/ShorakaTransaction.php b/app/ShorakaTransaction.php index f73dbb5..160a1ca 100644 --- a/app/ShorakaTransaction.php +++ b/app/ShorakaTransaction.php @@ -8,6 +8,7 @@ class ShorakaTransaction extends Model { protected $fillable = [ 'norujukan', + 'kodcaw', 'order_id', 'status', 'product_type', diff --git a/app/Support/v1/Penebusan.php b/app/Support/v1/Penebusan.php index 00bde97..08af1c5 100644 --- a/app/Support/v1/Penebusan.php +++ b/app/Support/v1/Penebusan.php @@ -11,8 +11,6 @@ use Carbon\Carbon; use App\Gadai; use App\HargaEmas; use App\InOutMar; -use App\KomuditiPurchase; -use App\KomuditiTransaksi; use App\Marhun; use App\Moratorium; use App\Palsu; @@ -809,19 +807,18 @@ class Penebusan $array_tebus = $request->only(['jeniskeuntungan', 'nopelanggan','jenistebus','addpinjaman','nowakil','marhun','teller','bakipinjaman','bakiupah']); $array_trans = $request->only(['nopelanggan', 'noic','jenisAT','pinjaman','pinjamanbaru','marhun','teller','jenistebus']); $array_tebus_base = $request->only(['totalbayaran', 'bakiupah','jeniskeuntungan','bakipinjaman','jenistebus','teller','nowakil','duitpelanggan']); - $array_trans_base = $request->only(['pinjaman','teller','pinjamanbaru']); try{ DB::connection($cawangan['mysql'])->beginTransaction(); $this->saveTebus($kodcaw,$norujukan,$gadai,$cawangan,$array_tebus); - $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans); + $newReference = $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans); DB::connection('mysql7')->beginTransaction(); $this->saveTebusBase($kodcaw,$norujukan,$gadai,$array_tebus_base); - $this->createTransactionBase($norujukan,$kodcaw,$array_trans_base); $array_response = [ 'result' => 'success', - 'data' => 1 + 'data' => 1, + 'norujukan_baru' => $newReference ]; DB::connection($cawangan['mysql'])->commit(); @@ -1077,37 +1074,11 @@ class Penebusan 't_update' => $current->toDateString(), )); + return $new_sag_number; }catch(\Exception $e){ throw new \Exception("Error in Transaction: " . $e->getMessage()); } } - private function createTransactionBase($norujukan,$kodcaw,$array_trans_base){ - $current = Carbon::now(); - - $komuditi_current = KomuditiPurchase::on('mysql7')->where('date_purchase','=',$current->toDateString())->orderBy('created_at','desc')->first(); - - $unit_current = isset($komuditi_current['unit_komuditi']) ? $komuditi_current['unit_komuditi']: 0; - - try{ - $update_unit = $unit_current - $array_trans_base['pinjamanbaru']; - - KomuditiPurchase::on('mysql7')->where('id','=',$komuditi_current['id'])->update([ - 'unit_komuditi' => $update_unit - ]); - - KomuditiTransaksi::on('mysql7')->create([ - 'unit_komuditi' => $update_unit, - 'nosijil'=>$komuditi_current['no_sijil'], - 'norujukan' => $norujukan, - 'kodcaw' => $kodcaw, - 'teller' => $array_trans_base['teller'], - 'masa' => $current->toTimeString(), - 'tarikh' => $current->toDateString() - ]); - }catch(\Exception $e){ - throw new \Exception("Error in TransactionBase: " . $e->getMessage()); - } - } -} \ No newline at end of file +} diff --git a/app/Support/v2/Penebusan.php b/app/Support/v2/Penebusan.php index 0afe15d..5a3f71b 100644 --- a/app/Support/v2/Penebusan.php +++ b/app/Support/v2/Penebusan.php @@ -12,8 +12,6 @@ use Carbon\Carbon; use App\Gadai; use App\HargaEmas; use App\InOutMar; -use App\KomuditiPurchase; -use App\KomuditiTransaksi; use App\Marhun; use App\FailoverTransaction; use App\FailoverTransactionFailed; @@ -984,20 +982,19 @@ class Penebusan $array_tebus = $request->only(['jeniskeuntungan', 'nopelanggan','jenistebus','addpinjaman','nowakil','marhun','teller']); $array_trans = $request->only(['nopelanggan', 'noic','jenisAT','pinjaman','pinjamanbaru','marhun','teller','jenistebus']); $array_tebus_base = $request->only(['totalbayaran', 'bakiupah','jeniskeuntungan','bakipinjaman','jenistebus','teller','nowakil','duitpelanggan']); - $array_trans_base = $request->only(['pinjaman','teller','pinjamanbaru']); try{ DB::connection($cawangan['mysql'])->beginTransaction(); $this->saveTebus($kodcaw,$norujukan,$gadai,$cawangan,$array_tebus); - $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans); + $newReference = $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans); DB::connection('mysql7')->beginTransaction(); $this->saveTebusBase($kodcaw,$norujukan,$gadai,$array_tebus_base); - $this->createTransactionBase($norujukan,$kodcaw,$array_trans_base); $array_response = [ 'result' => 'success', - 'data' => 1 + 'data' => 1, + 'norujukan_baru' => $newReference ]; DB::connection($cawangan['mysql'])->commit(); @@ -1044,19 +1041,18 @@ class Penebusan $array_tebus = $request->only(['jeniskeuntungan', 'nopelanggan','jenistebus','addpinjaman','nowakil','marhun','teller']); $array_trans = $request->only(['nopelanggan', 'noic','pinjaman','pinjamanbaru','marhun','teller','jenistebus']); $array_tebus_base = $request->only(['totalbayaran', 'bakiupah','jeniskeuntungan','bakipinjaman','jenistebus','teller','nowakil','duitpelanggan']); - $array_trans_base = $request->only(['pinjaman','teller','pinjamanbaru']); try{ DB::connection($cawangan['mysql'])->beginTransaction(); $this->saveTebus($kodcaw,$norujukan,$gadai,$cawangan,$array_tebus); - $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans); + $newReference = $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans); DB::connection('mysql7')->beginTransaction(); $this->saveTebusBase($kodcaw,$norujukan,$gadai,$array_tebus_base); - $this->createTransactionBase($norujukan,$kodcaw,$array_trans_base); $array_response = [ 'result' => 'success', - 'data' => 1 + 'data' => 1, + 'norujukan_baru' => $newReference ]; DB::connection($cawangan['mysql'])->commit(); @@ -1356,39 +1352,11 @@ class Penebusan 't_update' => $current->toDateString(), )); + return $new_sag_number; }catch(\Exception $e){ throw new \Exception("Error in Transaction: " . $e->getMessage()); } } - private function createTransactionBase($norujukan,$kodcaw,$array_trans_base){ - $current = Carbon::now(); - - $komuditi_current = KomuditiPurchase::on('mysql7')->where('date_purchase','=',$current->toDateString())->orderBy('created_at','desc')->first(); - - $unit_current = isset($komuditi_current['unit_komuditi']) ? $komuditi_current['unit_komuditi']: 0; - - try{ - $update_unit = $unit_current - $array_trans_base['pinjamanbaru']; - - KomuditiPurchase::on('mysql7')->where('id','=',$komuditi_current['id'])->update([ - 'unit_komuditi' => $update_unit - ]); - - KomuditiTransaksi::on('mysql7')->create([ - 'unit_komuditi' => $update_unit, - 'nosijil'=>$komuditi_current['no_sijil'], - 'norujukan' => $norujukan, - 'kodcaw' => $kodcaw, - 'teller' => $array_trans_base['teller'], - 'masa' => $current->toTimeString(), - 'tarikh' => $current->toDateString() - ]); - }catch(\Exception $e){ - throw new \Exception("Error in TransactionBase: " . $e->getMessage()); - } - } - - -} \ No newline at end of file +} diff --git a/database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php b/database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php new file mode 100644 index 0000000..96fe0b4 --- /dev/null +++ b/database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php @@ -0,0 +1,22 @@ +string('kodcaw', 20)->nullable()->index(); + }); + } + + public function down() + { + Schema::table('shoraka_transactions', function (Blueprint $table) { + $table->dropColumn('kodcaw'); + }); + } +} diff --git a/docs/shoraka-integration.md b/docs/shoraka-integration.md new file mode 100644 index 0000000..f3cb510 --- /dev/null +++ b/docs/shoraka-integration.md @@ -0,0 +1,147 @@ +# Shoraka STP integration and kaunter workflow + +Based on Shoraka Al-Amin STP API v1.5.0 (9 January 2026). + +## Agreed behavior + +New financing uses Shoraka instead of deducting internal commodity stock. +Cash/online financing completion and SAG printing wait for a Completed trade. +Existing historical commodity records retain their legacy reporting/printing path. + +The counter saves financing details, records a resumable local workflow, and +submits the trade. It redirects the teller to **Pembiayaan Shoraka**. +The teller uses **Semak Status**, then **Selesaikan Pembiayaan** when eligible. +This second step performs the cash update or registers the online payment for +the existing PC approval flow. It does not itself transfer funds through a bank. + +Ordinary financing, the two older refinancing paths, additional financing and +auto-tawarruq use this workflow. API refinancing returns norujukan_baru to ensure +the commodity trade belongs to the newly created financing, not the redeemed one. +Existing refinancing registration/settlement database writes still occur before +the commodity request; the counter cash completion and SAG are held afterwards. + +## Configuration and migrations + +API environment settings: + +- SHORAKA_BASE_URL: default https://uat2.shorakagroup.com +- SHORAKA_API_ID and SHORAKA_SECRET_KEY: supplied by Shoraka +- SHORAKA_TIMEOUT: seconds, default 60 + +The counter calls its existing API_URL; Shoraka credentials are kept in the API. +Register the public HTTPS /api/shoraka/callback URL and arrange server IP +whitelisting with Shoraka. Confirm the configured environment before UAT/live use. + +Deploy both repositories together and apply the following migrations using the +normal deployment process, before enabling counter traffic. They are not run +against application databases by the automated tests. + +In api-kopkb (configured default database; existing migration may already be applied): + +~~~sh +php artisan migrate --path=database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php +php artisan migrate --path=database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php +~~~ + +In kaunter-kopkb (counter application database): + +~~~sh +php artisan migrate --path=database/migrations/2026_10_05_120000_create_shoraka_financings_table.php +~~~ + +Clear/rebuild any deployment route, config and view caches through the usual +deployment process. Do not mix a newly deployed API with an old counter build. + +## Data mapping + +| Shoraka field | Source | +| --- | --- | +| ownership | Central customer.nama matched using gadai.nokp | +| ownership_doc_no | gadai.norujukan | +| order_amount | Saved gadai.pinjaman | +| murabaha_amount | Saved gadai.hargajualan | +| tenor / tenor_other / unit | OTHERS / 12 / MONTH | +| product / commodity / currency / order | FINANCING / 000-COPPER / MYR / Buy & Sell | + +Both current financing engines calculate the contract using upah12 and a +12-month contract end date. The elapsed-charge fields tempoh and tempohbayar +are not contractual tenor fields. + +The backend derives amounts and customer identity; it ignores client-supplied +amounts on the branch submission endpoint. Each Shoraka order stores kodcaw. +A completed response/callback synchronizes gadai.tagkomuditi to 1 only when saved +financing amounts still match. Contract edits after submission are rejected. +Payment completion also verifies the staged payout amount. + +## API endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | /api/shoraka/readiness | Configuration readiness (not a live connectivity probe) | +| GET | /api/shoraka/gadai/{kodcaw}/{norujukan} | Local order, financing amounts and completion eligibility | +| POST | /api/shoraka/gadai/{kodcaw}/{norujukan}/submit | Submit from saved financing data | +| POST | /api/shoraka/gadai/{kodcaw}/{norujukan}/refresh | Query Shoraka and synchronize branch status | +| GET | /api/shoraka/gadai/{kodcaw}/{norujukan}/certificate | Download the fixed-endpoint PDF after Completed | +| POST | /api/shoraka/callback | Verify signature, refresh and acknowledge | +| POST | /api/shoraka/orders | Existing standalone submission contract | +| POST | /api/shoraka/orders/{norujukan}/refresh | Existing standalone refresh | +| GET | /api/shoraka/orders/{norujukan}/certificate | Existing standalone PDF download | + +The old PUT /api/komuditi/transaksi endpoint now delegates to branch Shoraka +submission. New loans do not consume KomuditiPurchase stock. Historical stock +administration remains available; old stock update/cancel endpoints reject +references already associated with Shoraka. + +Counter /shoraka pages require session authentication, an authorized role and +the user's branch. Only the original teller can submit/finish a staged financing. +POST forms use CSRF protection. API endpoints retain the repository's existing +network/access-control arrangement; restrict API access accordingly. + +## Completion and recovery + +shoraka_financings is the counter's durable completion record: + +- WAITING: awaiting trade completion or teller completion. +- PROCESSING: one request has atomically claimed payment completion. +- DONE: completion returned successfully; saved request context is cleared. +- REVIEW: completion raised an error; do not retry payment automatically. + +Only one WAITING request can claim completion. Repeated requests for DONE do +not repeat cash/online processing. A worker crash while PROCESSING or an +ambiguous response during payment requires operational reconciliation. + +Shoraka PENDING/UNKNOWN/legacy FAILED entries block repeated submissions. +A timeout does not prove a remote trade failed. If an orderId is known, refresh. +Otherwise reconcile the reference with Shoraka and link/correct the record +through an authorized operational process before resubmission. + +A failure between remote financing registration and local workflow creation +also requires reconciliation using the newly created reference. Do not repeat +the original refinancing operation, which may already have settled the old loan. +The guide provides neither a reference-based lookup nor an idempotency key. + +Historical print paths remain for records with no Shoraka order/workflow. +For Shoraka financing, SAG metadata uses certificateDetails1, commodityType, +unit and volume from its own trade. The separate PDF is fetched from the signed +certificate endpoint. Missing certificate metadata blocks SAG printing. + +## Protocol choices and UAT checks + +- Appendix specifies OTHERS although prose says OTHER; use OTHERS. +- Section 5 specifies POST /api/certificate although history says GET; use POST. +- Order lookup/PDF signature: SECRET_KEY;API_ID;order_id. +- Callback camelCase strings are hashed in documented order without decimal normalization. +- ownershipDocNo and certificate fields are unsigned. Match a known orderId and + re-query current status rather than trusting those callback fields. +- Respond with the exact plain text OK only after status persistence/synchronization. +- Unknown callback orderId returns 404. An early callback needs redelivery or + subsequent refresh; the guide does not specify a retry policy. +- Confirm protocol inconsistencies, callback delivery and full certificate metadata in UAT. + +## Tests + +API: tests/ShorakaGadaiTest.php (includes the existing protocol regression tests). +Counter: tests/Feature/ShorakaFinancingTest.php. + +Use PHPUnit with PDO SQLite enabled. Tests use in-memory databases and fake +HTTP responses. They do not run live trades, payouts or branch migrations. diff --git a/routes/web.php b/routes/web.php index 1fba531..e34f9ce 100644 --- a/routes/web.php +++ b/routes/web.php @@ -119,8 +119,17 @@ $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']); + $router->get('shoraka/readiness', ['uses' => 'ShorakaGadaiController@readiness']); + $router->get('shoraka/gadai/{kodcaw}/{norujukan}', ['uses' => 'ShorakaGadaiController@showGadai']); + $router->post('shoraka/gadai/{kodcaw}/{norujukan}/submit', ['uses' => 'ShorakaGadaiController@submitGadai']); + $router->post('shoraka/gadai/{kodcaw}/{norujukan}/refresh', ['uses' => 'ShorakaGadaiController@refreshGadai']); + $router->get('shoraka/gadai/{kodcaw}/{norujukan}/certificate', ['uses' => 'ShorakaGadaiController@certificateGadai']); + // Shoraka Commodity API $router->post('shoraka/orders',['uses' => 'ShorakaController@submit']); + $router->post('shoraka/orders/{norujukan}/refresh', ['uses' => 'ShorakaController@refresh']); + $router->get('shoraka/orders/{norujukan}/certificate', ['uses' => 'ShorakaController@certificate']); + $router->post('shoraka/callback', ['uses' => 'ShorakaController@callback']); $router->get('check/serah/gadai',['uses'=>'GadaiController@checkSerahGadaian']); $router->get('check/serah/tebus',['uses'=>'TebusController@checkSerahTebus']); diff --git a/tests/ShorakaGadaiTest.php b/tests/ShorakaGadaiTest.php new file mode 100644 index 0000000..7ded558 --- /dev/null +++ b/tests/ShorakaGadaiTest.php @@ -0,0 +1,124 @@ +app['config']->set('database.connections.' . $connection, [ + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', + ]); + DB::purge($connection); + } + Schema::connection('mysql7')->create('arrahn_master_db', function (Blueprint $table) { + $table->string('kodcaw'); $table->string('mysql'); + }); + Schema::connection('mysql7')->create('customer', function (Blueprint $table) { + $table->string('kpbaru'); $table->string('kplama')->nullable(); $table->string('nama'); + }); + Schema::connection('mysql7')->create('komuditi_transaksi', function (Blueprint $table) { + $table->string('norujukan'); + }); + Schema::connection('branch_test')->create('gadai', function (Blueprint $table) { + $table->string('norujukan'); $table->string('nokp'); $table->string('kodcaw'); + $table->decimal('pinjaman', 15, 2); $table->decimal('hargajualan', 15, 2); + $table->integer('tagserah')->default(0); + $table->integer('tagkomuditi')->default(0); $table->integer('cetak')->default(0); + $table->string('sttebus')->default(''); + }); + DB::connection('mysql7')->table('arrahn_master_db')->insert([ + ['kodcaw' => 'KB2', 'mysql' => 'branch_test'], + ['kodcaw' => 'KB3', 'mysql' => 'branch_test'], + ]); + DB::connection('mysql7')->table('customer')->insert(['kpbaru' => 'IC-1', 'nama' => 'Nama Database']); + DB::connection('branch_test')->table('gadai')->insert([ + 'norujukan' => 'SAG-001', 'kodcaw' => 'KB2', 'nokp' => 'IC-1', + 'pinjaman' => 1000, 'hargajualan' => 1200, + ]); + } + + public function testBranchSubmissionUsesDatabaseAmountsAndSynchronizesTag(): void + { + $this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']); + $this->json('POST', '/api/shoraka/gadai/KB2/SAG-001/submit', [ + 'order_amount' => 1, 'ownership' => 'Tampered', + ])->seeStatusCode(200); + Http::assertSent(function ($request) { + return $request['order_amount'] === '1000.00' + && $request['murabaha_amount'] === '1200.00' + && $request['ownership'] === 'Nama Database' + && (int) $request['tenor_other'] === 12; + }); + $this->assertSame('KB2', ShorakaTransaction::first()->kodcaw); + $this->assertEquals(1, DB::connection('branch_test')->table('gadai')->value('tagkomuditi')); + $this->json('GET', '/api/shoraka/gadai/KB2/SAG-001')->seeJson(['can_complete' => true]); + } + + public function testPendingOrderKeepsTagUnsetAndBlocksSag(): void + { + $this->fakeResponse(['orderId' => '9999', 'status' => 'Active']); + $this->json('POST', '/api/shoraka/gadai/KB2/SAG-001/submit')->seeStatusCode(200); + $this->assertEquals(0, DB::connection('branch_test')->table('gadai')->value('tagkomuditi')); + $this->json('PUT', '/api/gadai/norujukan/cetak/KB2/SAG-001')->seeStatusCode(409); + } + + public function testLegacyPurchaseCannotBeSubmittedAgain(): void + { + DB::connection('mysql7')->table('komuditi_transaksi')->insert(['norujukan' => 'SAG-001']); + Http::fake(); + $this->json('POST', '/api/shoraka/gadai/KB2/SAG-001/submit')->seeStatusCode(409); + Http::assertNothingSent(); + } + + public function testChangedFinancingCannotBeCompleted(): void + { + $record = $this->transaction('Completed'); + $record->update(['kodcaw' => 'KB2']); + DB::connection('branch_test')->table('gadai')->update(['pinjaman' => 900]); + $this->json('GET', '/api/shoraka/gadai/KB2/SAG-001')->seeJson(['can_complete' => false]); + $this->json('GET', '/api/shoraka/gadai/KB2/SAG-001/certificate')->seeStatusCode(409); + } + + public function testDifferentBranchCannotReadOrder(): void + { + $record = $this->transaction('Completed'); + $record->update(['kodcaw' => 'KB2']); + $this->json('GET', '/api/shoraka/gadai/KB3/SAG-001')->seeStatusCode(404); + } + + public function testHistoricalFinancingWithoutCommodityLedgerCanStillBeHandedOver(): void + { + Http::fake(); + $this->json('PUT', '/api/gadai/norujukan/serah/KB2/SAG-001')->seeStatusCode(200); + $this->assertEquals(1, DB::connection('branch_test')->table('gadai')->value('tagserah')); + Http::assertNothingSent(); + } + + public function testSubmittedContractCannotBeRegisteredAgain(): void + { + $this->transaction('Completed')->update(['kodcaw' => 'KB2']); + Http::fake(); + $this->json('PUT', '/api/gadai/norujukan/KB2/SAG-001', [ + 'kodcaw' => 'KB2', 'norujukan' => 'SAG-001', + ])->seeStatusCode(409); + Http::assertNothingSent(); + } + + public function testShorakaOrderCannotUpdateOrCancelLegacyStock(): void + { + $this->transaction('Completed')->update(['kodcaw' => 'KB2']); + Http::fake(); + $this->json('PUT', '/api/komuditi/update', ['norujukan' => 'SAG-001'])->seeStatusCode(409); + $this->json('PUT', '/api/komuditi/batal', ['norujukan' => 'SAG-001'])->seeStatusCode(409); + Http::assertNothingSent(); + } +} diff --git a/tests/ShorakaIntegrationTest.php b/tests/ShorakaIntegrationTest.php new file mode 100644 index 0000000..d5d1e34 --- /dev/null +++ b/tests/ShorakaIntegrationTest.php @@ -0,0 +1,258 @@ +app['config']->set('services.shoraka', [ + 'base_url' => 'https://shoraka.test', + 'api_id' => 'API_ID', + 'secret_key' => 'SECRET_KEY', + 'timeout' => 5, + ]); + // All database writes stay in memory; never run migrations on branch databases. + $this->app['config']->set('database.default', 'shoraka_test'); + $this->app['config']->set('database.connections.shoraka_test', [ + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', + ]); + DB::purge('shoraka_test'); + require_once __DIR__ . '/../database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php'; + (new CreateShorakaTransactionsTable())->up(); + require_once __DIR__ . '/../database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php'; + (new AddKodcawToShorakaTransactions())->up(); + Http::swap(new Factory()); + // Fail any HTTP request that a test did not explicitly stub. + Log::shouldReceive('error')->zeroOrMoreTimes(); + } + + protected function fakeResponse($body, array $headers = [], int $status = 200): void + { + Http::fake(function ($request) use ($body, $headers, $status) { + $this->assertStringStartsWith('https://shoraka.test/api/', $request->url()); + return Http::response($body, $status, $headers); + }); + } + + protected function submission(): array + { + return [ + 'norujukan' => 'SAG-001', 'ownership' => 'Abu Samad', + 'order_amount' => '1000.00', 'murabaha_amount' => '1200.00', + 'tenor_other' => 6, + ]; + } + + protected function transaction(string $status = 'Active', $orderId = '9999'): ShorakaTransaction + { + return ShorakaTransaction::create([ + 'norujukan' => 'SAG-001', 'order_id' => $orderId, 'status' => $status, + 'product_type' => 'FINANCING', 'commodity_type' => '000-COPPER', + 'ownership_name' => 'Abu Samad', 'order_amount' => '1000.00', + 'murabaha_amount' => '1200.00', 'attempt_count' => 1, + ]); + } + + protected function callbackPayload(): array + { + // Published signature example, v1.5 page 14. + return [ + 'apiId' => 'API_ID', 'orderId' => '9999', 'status' => 'Completed', + 'bankName' => 'Test Bank Bhd', 'ownershipName' => 'Abu Samad', + 'commodityType' => '000-COPPER', 'unit' => 'Tonne', 'volume' => '245.678901', + 'productType' => 'FINANCING', 'valueDate' => '08/12/2025', 'cancelDate' => '', + 'orderType' => 'Buy & Sell', 'orderCurrency' => 'MYR', + 'orderAmount' => '1000000.00', 'murabahaAmount' => '1000000.00', + 'tenor' => 'O/N', 'tenorOther' => '', 'tenorOtherUnit' => '', + 'signature' => 'bbc6f59e9c64aea9e30f3c03d30f63e8fc37898d2699c0cdd41e416a518aa122', + ]; + } + + public function testSubmitSignatureMatchesPublishedExample(): void + { + $this->fakeResponse(['orderId' => '12345', 'status' => 'Completed']); + (new ShorakaService())->submitOrder([ + 'product_type' => 'DEPOSIT', 'commodity_type' => '000-COPPER', + 'ownership' => 'Abu Samad', 'value_date' => '10/11/2022', + 'order_currency' => 'MYR', 'order_amount' => 1000000, + 'murabaha_amount' => 1000000, 'tenor' => 'O/N', + 'order_type' => 'Buy & Sell', 'ownership_doc_no' => 'SAG-001', + ]); + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request->url() === 'https://shoraka.test/api/submitorder' + && $request->hasHeader('Content-Type', 'application/x-www-form-urlencoded') + && $request['order_amount'] === '1000000.00' + && $request['ownership_doc_no'] === 'SAG-001' + && $request['signature'] === '43970ac7a5c3867133af0dd92fd5eef9a7b415ebdb8b0d1025d4686bc25693bd'; + }); + } + + public function testStatusUsesPublishedSignatureAndChecksOrderIdentity(): void + { + $this->fakeResponse(['orderId' => '12345', 'status' => 'Active']); + (new ShorakaService())->orderStatus('12345'); + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request->url() === 'https://shoraka.test/api/orderstatus' + && $request['signature'] === 'add9890b2a08cf47290e8869d4a25b7f4b73cb075c20aa95aa47d1bbfe6b62fc'; + }); + $this->expectException(RuntimeException::class); + (new ShorakaService())->orderStatus('another-order'); + } + + public function testCallbackSignatureMatchesPublishedExampleAndRejectsTampering(): void + { + $service = new ShorakaService(); + $payload = $this->callbackPayload(); + $this->assertTrue($service->validCallback($payload)); + foreach (ShorakaService::CALLBACK_FIELDS as $field) { + $changed = $payload; + $changed[$field] .= 'x'; + $this->assertFalse($service->validCallback($changed), $field); + $missing = $payload; + unset($missing[$field]); + $this->assertFalse($service->validCallback($missing), $field); + } + $payload['apiId'] = 'wrong'; + $this->assertFalse($service->validCallback($payload)); + } + + public function testSubmitPersistsOrderAndRejectsDuplicate(): void + { + $this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']); + $this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(200); + $record = ShorakaTransaction::first(); + $this->assertSame('9999', $record->order_id); + $this->assertSame('Completed', $record->status); + $this->assertNotNull($record->completed_at); + $this->assertArrayNotHasKey('signature', $record->request_payload); + $this->assertSame('OTHERS', $record->request_payload['tenor']); + $this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409); + Http::assertSentCount(1); + } + + public function testAmbiguousFailureBlocksResubmissionAndHidesUpstreamBody(): void + { + $this->fakeResponse('SECRET_KEY confidential', [], 500); + $this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(502); + $this->assertStringNotContainsString('SECRET_KEY', $this->response->getContent()); + $record = ShorakaTransaction::first(); + $this->assertSame('UNKNOWN', $record->status); + $this->assertStringNotContainsString('SECRET_KEY', $record->error_message); + $this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409); + Http::assertSentCount(1); + } + + public function testPendingSubmissionCannotBeSubmittedAgain(): void + { + $this->transaction('PENDING', null); + Http::fake(); + $this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409); + Http::assertNothingSent(); + } + + public function testRefreshUpdatesStatus(): void + { + $record = $this->transaction(); + $this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']); + $this->json('POST', '/api/shoraka/orders/SAG-001/refresh')->seeStatusCode(200); + $this->assertSame('Completed', $record->fresh()->status); + $this->assertNotNull($record->fresh()->completed_at); + } + + public function testCallbackAcknowledgesAndUsesCurrentStatusNotUnsignedFields(): void + { + $record = $this->transaction(); + $payload = $this->callbackPayload(); + $payload['certificateUrl'] = 'https://untrusted.test/file'; + $payload['ownershipDocNo'] = 'other-reference'; + $this->fakeResponse([ + 'orderId' => '9999', 'status' => 'Cancelled', + 'certificateUrl' => 'https://shoraka.test/certificate/9999', + ]); + $this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(200); + $this->assertSame('OK', $this->response->getContent()); + $this->assertSame('Cancelled', $record->fresh()->status); + $this->assertSame('https://shoraka.test/certificate/9999', $record->fresh()->certificate_url); + $this->assertArrayNotHasKey('signature', $record->fresh()->callback_payload); + // Repeated delivery is acknowledged without creating another order. + $this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(200); + $this->assertSame(1, ShorakaTransaction::count()); + } + + public function testInvalidCallbackDoesNotMutateOrCallUpstream(): void + { + $record = $this->transaction(); + Http::fake(); + $payload = $this->callbackPayload(); + $payload['status'] = 'Cancelled'; + $this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(401); + $this->assertSame('Active', $record->fresh()->status); + Http::assertNothingSent(); + } + + public function testUnknownCallbackIsNotAcknowledged(): void + { + Http::fake(); + $this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(404); + Http::assertNothingSent(); + } + + public function testFailedCallbackRefreshPreservesRecord(): void + { + $record = $this->transaction(); + $this->fakeResponse('unavailable', [], 503); + $this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(502); + $this->assertSame('Active', $record->fresh()->status); + $this->assertNull($record->fresh()->callback_payload); + } + + public function testCertificateReturnsPdfFromFixedEndpoint(): void + { + $this->transaction('Completed', '12345'); + $pdf = "%PDF-1.4\nexample"; + $this->fakeResponse($pdf, ['Content-Type' => 'application/pdf']); + $this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(200); + $this->assertSame($pdf, $this->response->getContent()); + $this->assertSame('application/pdf', $this->response->headers->get('Content-Type')); + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request->url() === 'https://shoraka.test/api/certificate' + && $request['signature'] === 'add9890b2a08cf47290e8869d4a25b7f4b73cb075c20aa95aa47d1bbfe6b62fc'; + }); + } + + public function testCertificateRejectsNonPdf(): void + { + $this->transaction(); + $this->fakeResponse(['error' => 'not ready']); + $this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(502); + } + + public function testMissingCredentialsPreventRequests(): void + { + $this->app['config']->set('services.shoraka.secret_key', ''); + Http::fake(); + $this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(503); + $this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(503); + $this->assertSame(0, ShorakaTransaction::count()); + Http::assertNothingSent(); + } + + public function testMissingOrderIdCannotBeRefreshedOrDownloaded(): void + { + $this->transaction('UNKNOWN', null); + Http::fake(); + $this->json('POST', '/api/shoraka/orders/SAG-001/refresh')->seeStatusCode(409); + $this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(409); + Http::assertNothingSent(); + } +} -- 2.52.0 From d0f08545d1a574a9abbfa49892fb12b63b7c858f Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Sun, 11 Oct 2026 14:58:02 +0800 Subject: [PATCH 3/8] DONE: add sectigo certs to docker container --- docker/php/Dockerfile | 14 ++++++-- ...public-server-authentication-ca-dv-r36.crt | 36 +++++++++++++++++++ ...-public-server-authentication-root-r46.crt | 32 +++++++++++++++++ docker/php/entrypoint.sh | 9 +++++ docker/php/php.ini | 2 ++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 docker/php/certs/sectigo-public-server-authentication-ca-dv-r36.crt create mode 100644 docker/php/certs/sectigo-public-server-authentication-root-r46.crt diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 45aac51..0d25483 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -2,7 +2,15 @@ FROM php:7.4-fpm AS base WORKDIR /var/www/html -RUN apt-get update \ +# Debian 11 (bullseye) is EOL; deb.debian.org returns 404 for security debs. +RUN printf '%s\n' \ + 'deb http://archive.debian.org/debian bullseye main' \ + 'deb http://archive.debian.org/debian-security bullseye-security main' \ + 'deb http://archive.debian.org/debian bullseye-updates main' \ + > /etc/apt/sources.list \ + && printf 'Acquire::Check-Valid-Until "false";\n' \ + > /etc/apt/apt.conf.d/99no-check-valid-until \ + && apt-get update \ && apt-get install -y --no-install-recommends \ git \ pkg-config \ @@ -27,8 +35,10 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini COPY docker/php/zz-clear-env.conf /usr/local/etc/php-fpm.d/zz-clear-env.conf COPY docker/php/entrypoint.sh /usr/local/bin/app-entrypoint.sh +COPY docker/php/certs/*.crt /usr/local/share/ca-certificates/ -RUN chmod +x /usr/local/bin/app-entrypoint.sh \ +RUN update-ca-certificates \ + && chmod +x /usr/local/bin/app-entrypoint.sh \ && usermod -u 1000 www-data && groupmod -g 1000 www-data ENTRYPOINT ["app-entrypoint.sh"] diff --git a/docker/php/certs/sectigo-public-server-authentication-ca-dv-r36.crt b/docker/php/certs/sectigo-public-server-authentication-ca-dv-r36.crt new file mode 100644 index 0000000..af4a9d6 --- /dev/null +++ b/docker/php/certs/sectigo-public-server-authentication-ca-dv-r36.crt @@ -0,0 +1,36 @@ +-----BEGIN CERTIFICATE----- +MIIGTDCCBDSgAwIBAgIQOXpmzCdWNi4NqofKbqvjsTANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRFYgUjM2MIIBojANBgkqhkiG9w0B +AQEFAAOCAY8AMIIBigKCAYEAljZf2HIz7+SPUPQCQObZYcrxLTHYdf1ZtMRe7Yeq +RPSwygz16qJ9cAWtWNTcuICc++p8Dct7zNGxCpqmEtqifO7NvuB5dEVexXn9RFFH +12Hm+NtPRQgXIFjx6MSJcNWuVO3XGE57L1mHlcQYj+g4hny90aFh2SCZCDEVkAja +EMMfYPKuCjHuuF+bzHFb/9gV8P9+ekcHENF2nR1efGWSKwnfG5RawlkaQDpRtZTm +M64TIsv/r7cyFO4nSjs1jLdXYdz5q3a4L0NoabZfbdxVb+CUEHfB0bpulZQtH1Rv +38e/lIdP7OTTIlZh6OYL6NhxP8So0/sht/4J9mqIGxRFc0/pC8suja+wcIUna0HB +pXKfXTKpzgis+zmXDL06ASJf5E4A2/m+Hp6b84sfPAwQ766rI65mh50S0Di9E3Pn +2WcaJc+PILsBmYpgtmgWTR9eV9otfKRUBfzHUHcVgarub/XluEpRlTtZudU5xbFN +xx/DgMrXLUAPaI60fZ6wA+PTAgMBAAGjggGBMIIBfTAfBgNVHSMEGDAWgBRWc1hk +lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQUaMASFhgOr872h6YyV6NGUV3LBycw +DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI +KwYBBQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgEw +VAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdv +UHVibGljU2VydmVyQXV0aGVudGljYXRpb25Sb290UjQ2LmNybDCBhAYIKwYBBQUH +AQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3Rp +Z29QdWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYucDdjMCMGCCsGAQUF +BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA +YtOC9Fy+TqECFw40IospI92kLGgoSZGPOSQXMBqmsGWZUQ7rux7cj1du6d9rD6C8 +ze1B2eQjkrGkIL/OF1s7vSmgYVafsRoZd/IHUrkoQvX8FZwUsmPu7amgBfaY3g+d +q1x0jNGKb6I6Bzdl6LgMD9qxp+3i7GQOnd9J8LFSietY6Z4jUBzVoOoz8iAU84OF +h2HhAuiPw1ai0VnY38RTI+8kepGWVfGxfBWzwH9uIjeooIeaosVFvE8cmYUB4TSH +5dUyD0jHct2+8ceKEtIoFU/FfHq/mDaVnvcDCZXtIgitdMFQdMZaVehmObyhRdDD +4NQCs0gaI9AAgFj4L9QtkARzhQLNyRf87Kln+YU0lgCGr9HLg3rGO8q+Y4ppLsOd +unQZ6ZxPNGIfOApbPVf5hCe58EZwiWdHIMn9lPP6+F404y8NNugbQixBber+x536 +WrZhFZLjEkhp7fFXf9r32rNPfb74X/U90Bdy4lzp3+X1ukh1BuMxA/EEhDoTOS3l +7ABvc7BYSQubQ2490OcdkIzUh3ZwDrakMVrbaTxUM2p24N6dB+ns2zptWCva6jzW +r8IWKIMxzxLPv5Kt3ePKcUdvkBU/smqujSczTzzSjIoR5QqQA6lN1ZRSnuHIWCvh +JEltkYnTAH41QJ6SAWO66GrrUESwN/cgZzL4JLEqz1Y= +-----END CERTIFICATE----- diff --git a/docker/php/certs/sectigo-public-server-authentication-root-r46.crt b/docker/php/certs/sectigo-public-server-authentication-root-r46.crt new file mode 100644 index 0000000..71afc16 --- /dev/null +++ b/docker/php/certs/sectigo-public-server-authentication-root-r46.crt @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa +ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz +SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf +iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X +ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3 +IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS +VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE +SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu ++Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt +8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L +HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt +zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P +AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ +YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52 +gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA +Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB +JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX +DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui +TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5 +dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65 +LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp +0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY +QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE----- diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index a9951a7..a5d89f3 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -3,6 +3,15 @@ set -e cd /var/www/html +# Point PHP/cURL at the system CA bundle and trust Sectigo R36/R46 for Shoraka TLS. +if [ -f docker/php/php.ini ]; then + cp docker/php/php.ini /usr/local/etc/php/conf.d/app.ini +fi +if [ -d docker/php/certs ]; then + cp docker/php/certs/*.crt /usr/local/share/ca-certificates/ 2>/dev/null || true + update-ca-certificates >/dev/null 2>&1 || true +fi + if [ "${USE_STAGING_ENV:-false}" = "true" ] && [ -f .env.staging ]; then cp .env.staging .env fi diff --git a/docker/php/php.ini b/docker/php/php.ini index 3e9df60..70d532b 100644 --- a/docker/php/php.ini +++ b/docker/php/php.ini @@ -2,3 +2,5 @@ memory_limit=512M upload_max_filesize=50M post_max_size=50M max_execution_time=120 +openssl.cafile=/etc/ssl/certs/ca-certificates.crt +curl.cainfo=/etc/ssl/certs/ca-certificates.crt -- 2.52.0 From a8888e66d112487fa03d1d4b063e7b141f732c34 Mon Sep 17 00:00:00 2001 From: afrina Date: Mon, 19 Oct 2026 13:02:13 +0800 Subject: [PATCH 4/8] feat(shoraka): defer gadai persistence until commodity confirmation and add simulation support --- app/Http/Controllers/GadaiController.php | 59 ++++- app/Http/Controllers/MarhunController.php | 50 ++++ app/Http/Controllers/ShorakaController.php | 12 +- .../Controllers/ShorakaGadaiController.php | 11 +- app/Services/ShorakaGadaiService.php | 9 +- app/Services/ShorakaNewGadaiService.php | 223 ++++++++++++++++++ app/Services/ShorakaService.php | 54 +++++ app/Support/v1/GadaiV1.php | 20 +- app/Support/v1/Penebusan.php | 3 +- app/Support/v2/GadaiV2.php | 13 +- app/Support/v2/Penebusan.php | 3 +- config/services.php | 2 + 12 files changed, 429 insertions(+), 30 deletions(-) create mode 100644 app/Services/ShorakaNewGadaiService.php diff --git a/app/Http/Controllers/GadaiController.php b/app/Http/Controllers/GadaiController.php index bba2619..9d795c6 100644 --- a/app/Http/Controllers/GadaiController.php +++ b/app/Http/Controllers/GadaiController.php @@ -55,6 +55,10 @@ class GadaiController extends Controller $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$cawangan_code)->first(); $gadaiTransaction = Gadai::on($cawangan['mysql'])->where('t_gadai','=',$date)->orderBy('sirig', 'desc')->first(); + $serial = app(\App\Services\ShorakaNewGadaiService::class)->latestSerial($cawangan_code, $date); + if ($serial > (int) ($gadaiTransaction->sirig ?? 0)) { + $gadaiTransaction = ['sirig' => $serial]; + } }catch(\Exception $e){ Log::debug($e); Log::debug($cawangan_code); @@ -111,7 +115,7 @@ class GadaiController extends Controller $gadai_all = []; foreach($cawangan_all as $index=>$cawangan){ - $gadai = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); + $gadai = collect(array_filter([app(\App\Services\ShorakaNewGadaiService::class)->find($cawangan->kodcaw, $norujukan)])); if(sizeof($gadai) != 0){ array_push($gadai_all,$gadai); } @@ -130,7 +134,7 @@ class GadaiController extends Controller $gadai_all = []; foreach($cawangan_all as $index=>$cawangan){ - $gadai = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); + $gadai = collect(array_filter([app(\App\Services\ShorakaNewGadaiService::class)->find($cawangan->kodcaw, $norujukan)])); if(sizeof($gadai) != 0){ array_push($gadai_all,$gadai); } @@ -152,13 +156,22 @@ class GadaiController extends Controller public function showOneGadaibyRujukanAndCawangan($kodcaw,$norujukan){ $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); - $gadai = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); + $gadai = collect(array_filter([app(\App\Services\ShorakaNewGadaiService::class)->find($cawangan->kodcaw, $norujukan)])); return response()->json($gadai); } public function create($kodcaw,Request $request){ + if ($request->boolean('shoraka_new')) { + $this->validate($request, ['nokp' => 'required|string', 'nopelanggan' => 'required']); + return response()->json(app(\App\Services\ShorakaNewGadaiService::class)->reserve( + $kodcaw, $request->only(['nokp', 'nopelanggan']) + ), 201); + } + if (app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, (string) $request->norujukan)) { + abort(409, 'Nombor rujukan sedang digunakan. Sila dapatkan nombor baharu.'); + } $master = new MasterServices($kodcaw); $response = $master->createGadai($request); @@ -228,14 +241,18 @@ class GadaiController extends Controller } - public function daftarGadaiMarhun($norujukan,Request $request){ - if (\App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) { + public function daftarGadaiMarhun($kodcaw, $norujukan, Request $request) + { + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { abort(409, 'Kontrak telah dihantar ke Shoraka dan tidak boleh diubah.'); } - // dd("ggwp"); - $master = new MasterServices($request->kodcaw); - $response = $master->createMarhunGadai($request->norujukan,$request); - + $temporary = app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan); + if ($temporary && (string) $temporary['gadai']['nokp'] !== (string) $request->nokp) { + abort(409, 'Pelanggan tidak sepadan dengan borang gadaian sementara.'); + } + $request->merge(['kodcaw' => $kodcaw, 'norujukan' => $norujukan]); + $master = new MasterServices($kodcaw); + $response = $master->createMarhunGadai($norujukan, $request); return response()->json($response, 200); } @@ -249,7 +266,13 @@ class GadaiController extends Controller public function checkNorujukan(Request $request){ $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$request->kodcaw)->first(); - $gadai = Gadai::on($cawangan['mysql'])->where([['norujukan','=',$request->norujukan],['nokp','=',$request->nokp]])->first(); + $gadai = app(\App\Services\ShorakaNewGadaiService::class)->find($request->kodcaw, $request->norujukan); + if (!$gadai && \App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) { + abort(409, 'Transaksi Shoraka wujud tetapi maklumat sementara tiada. Hubungi operasi; jangan daftar semula.'); + } + if ($gadai && $gadai->nokp !== $request->nokp) { + $gadai = null; + } return response()->json($gadai); } @@ -372,6 +395,16 @@ class GadaiController extends Controller } public function batalGadai($kodcaw, $norujukan,Request $request){ + $temporary = app(\App\Services\ShorakaNewGadaiService::class); + $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); + if ($order && !empty($order->request_payload['_new_gadai'])) { + abort(409, 'Transaksi Shoraka telah dihantar. Semakan operasi diperlukan sebelum pembatalan.'); + } + if ($temporary->pending($kodcaw, $norujukan)) { + $temporary->abandon($kodcaw, $norujukan); + return response('Batal Berjaya', 200); + } + $current = Carbon::now(); $current = new Carbon(); @@ -426,7 +459,8 @@ class GadaiController extends Controller } public function cetakSAG($kodcaw,$norujukan,Request $request){ - if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists() + || app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan)) { app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan); } @@ -454,7 +488,8 @@ class GadaiController extends Controller } public function serahMarhun($kodcaw,$norujukan){ - if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists() + || app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan)) { app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan); } diff --git a/app/Http/Controllers/MarhunController.php b/app/Http/Controllers/MarhunController.php index cf5d893..2df9169 100644 --- a/app/Http/Controllers/MarhunController.php +++ b/app/Http/Controllers/MarhunController.php @@ -26,6 +26,9 @@ class MarhunController extends Controller $marhun_all = []; foreach($cawangan_all as $index=>$cawangan){ $marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); + if ($marhun->isEmpty()) { + $marhun = app(\App\Services\ShorakaNewGadaiService::class)->items($cawangan->kodcaw, $norujukan); + } if(sizeof($marhun) != 0){ array_push($marhun_all,$marhun); } @@ -40,6 +43,9 @@ class MarhunController extends Controller $marhun = Marhun::on($cawangan['mysql'])->where('norujukan',$request->norujukan)->distinct()->pluck('karat')->toArray(); + if (app(\App\Services\ShorakaNewGadaiService::class)->pending($request->kodcaw, $request->norujukan)) { + $marhun = app(\App\Services\ShorakaNewGadaiService::class)->items($request->kodcaw, $request->norujukan)->pluck('karat')->unique()->all(); + } $mutu = HargaEmas::on('mysql7')->whereIn('karat',$marhun)->pluck('keterangan')->toArray(); return response()->json($mutu); @@ -59,6 +65,11 @@ class MarhunController extends Controller public function create($kodcaw,Request $request) { + $order = \App\ShorakaTransaction::where('norujukan', $request->norujukan)->first(); + if ($order && !empty($order->request_payload['_new_gadai'])) { + abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); + } + $current = Carbon::now(); $current = new Carbon(); @@ -96,6 +107,10 @@ class MarhunController extends Controller }; $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); + $temporary = app(\App\Services\ShorakaNewGadaiService::class); + if ($temporary->pending($kodcaw, $request->norujukan)) { + return response()->json($temporary->changeItem($kodcaw, $request->norujukan, $marhunToCreate), 201); + } $marhun = Marhun::on($cawangan['mysql'])->create($marhunToCreate); $audit = new Audit(); @@ -127,6 +142,17 @@ class MarhunController extends Controller public function deletemarhun($kodcaw,$norujukan,Request $request) { + $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); + if ($order && !empty($order->request_payload['_new_gadai'])) { + abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); + } + + $temporary = app(\App\Services\ShorakaNewGadaiService::class); + if ($temporary->pending($kodcaw, $norujukan)) { + $temporary->changeItem($kodcaw, $norujukan, [], $request->recno, true); + return response('Deleted Successfully', 200); + } + $current = Carbon::now(); $current = new Carbon(); @@ -152,6 +178,11 @@ class MarhunController extends Controller public function editmarhun($kodcaw,$norujukan,Request $request) { + $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); + if ($order && !empty($order->request_payload['_new_gadai'])) { + abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); + } + $current = Carbon::now(); $current = new Carbon(); @@ -159,6 +190,17 @@ class MarhunController extends Controller $harga = HargaEmas::on('mysql7')->where('karat',$request->karat)->first(); $nilai_marhun = $request->berat * $harga['harga']; + $temporary = app(\App\Services\ShorakaNewGadaiService::class); + if ($temporary->pending($kodcaw, $norujukan)) { + $temporary->changeItem($kodcaw, $norujukan, [ + 'karat' => $request->karat, 'marhun' => $request->marhun, 'nota' => $request->nota, + 'tag_permata' => $request->tag_permata, + 'berat_batu_permata' => $request->berat_batu_permata ?: null, + 'berat' => $request->berat, 'harga' => $harga['harga'], 'n_marhun' => $nilai_marhun, + 'bil' => $request->kuantiti, + ], $request->recno); + return response()->json(true); + } $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); // Lock the parent first so concurrent edits to this SAG are serialized. @@ -257,6 +299,9 @@ class MarhunController extends Controller $marhuns = Marhun::on($cawangan['mysql'])->where('norujukan',$request->norujukan)->get(); + if ($marhuns->isEmpty()) { + $marhuns = app(\App\Services\ShorakaNewGadaiService::class)->items($request->kodcaw, $request->norujukan); + } $totalnilaimarhun = 0; foreach($marhuns as $index=>$marhun){ @@ -270,6 +315,11 @@ class MarhunController extends Controller public function getMarhunCaw($kodcaw,$norujukan) { + $temporary = app(\App\Services\ShorakaNewGadaiService::class); + if ($temporary->pending($kodcaw, $norujukan)) { + return response()->json($temporary->items($kodcaw, $norujukan)->sum('n_marhun')); + } + $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); $data = Marhun::on($cawangan['mysql']) ->where('norujukan','=', $norujukan) diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php index 3b9ae02..4f19c01 100644 --- a/app/Http/Controllers/ShorakaController.php +++ b/app/Http/Controllers/ShorakaController.php @@ -48,7 +48,7 @@ class ShorakaController extends Controller try { // The unique norujukan index is the atomic claim for concurrent submissions. - $transaction = ShorakaTransaction::create([ + $attributes = [ 'norujukan' => $validated['norujukan'], 'kodcaw' => $request->attributes->get('shoraka_kodcaw'), 'status' => 'PENDING', @@ -58,9 +58,15 @@ class ShorakaController extends Controller 'ownership_doc_no' => $payload['ownership_doc_no'], 'order_amount' => $payload['order_amount'], 'murabaha_amount' => $payload['murabaha_amount'], - 'request_payload' => $payload, + 'request_payload' => $request->attributes->get('shoraka_new') + ? array_merge($payload, ['_new_gadai' => true]) : $payload, 'attempt_count' => 1, - ]); + ]; + $create = function () use ($attributes) { return ShorakaTransaction::create($attributes); }; + $transaction = $request->attributes->get('shoraka_new') + ? app(\App\Services\ShorakaNewGadaiService::class)->claim( + $request->attributes->get('shoraka_kodcaw'), $validated['norujukan'], $payload, $create + ) : $create(); } catch (QueryException $exception) { $existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first(); if ($existing) { diff --git a/app/Http/Controllers/ShorakaGadaiController.php b/app/Http/Controllers/ShorakaGadaiController.php index c25383b..990be02 100644 --- a/app/Http/Controllers/ShorakaGadaiController.php +++ b/app/Http/Controllers/ShorakaGadaiController.php @@ -25,7 +25,8 @@ class ShorakaGadaiController extends ShorakaController 'order_amount' => $gadai->pinjaman, 'murabaha_amount' => $gadai->hargajualan, 'order' => $order ? $this->orderData($order) : null, 'details' => $order ? $order->response_payload : null, - 'can_complete' => $order && $order->status === 'Completed' + 'simulation' => $order && strpos((string) $order->order_id, 'SIM-') === 0, + 'can_complete' => $gadai->exists && $order && $order->status === 'Completed' && number_format((float) $gadai->pinjaman, 2, '.', '') === $order->order_amount && number_format((float) $gadai->hargajualan, 2, '.', '') === $order->murabaha_amount, ], @@ -44,8 +45,16 @@ class ShorakaGadaiController extends ShorakaController || $gadai->cetak || $gadai->sttebus) { abort(409, 'Gadaian lama atau telah selesai tidak boleh dihantar sebagai order baharu.'); } + $temporary = app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan); + if ($temporary && $gadai->exists) { + abort(409, 'Rujukan sementara bertindih dengan gadaian sedia ada. Semakan operasi diperlukan.'); + } + if ($temporary && !$temporary['prepared']) { + abort(409, 'Sila sahkan semula amaun gadaian selepas perubahan marhun.'); + } $request = new Request($gadaiService->payload($gadai)); $request->attributes->set('shoraka_kodcaw', $kodcaw); + $request->attributes->set('shoraka_new', !$gadai->exists); return $this->submit($request, $shoraka); } diff --git a/app/Services/ShorakaGadaiService.php b/app/Services/ShorakaGadaiService.php index f091b3e..027ebd9 100644 --- a/app/Services/ShorakaGadaiService.php +++ b/app/Services/ShorakaGadaiService.php @@ -13,7 +13,8 @@ class ShorakaGadaiService public function gadai(string $kodcaw, string $norujukan): Gadai { $branch = Cawangan::on('mysql7')->where('kodcaw', $kodcaw)->firstOrFail(); - return Gadai::on($branch->mysql)->where('norujukan', $norujukan)->where('kodcaw', $kodcaw)->firstOrFail(); + return app(ShorakaNewGadaiService::class)->find($kodcaw, $norujukan) + ?? abort(404, 'Maklumat gadaian tidak tersedia. Semakan diperlukan.'); } public function payload(Gadai $gadai): array @@ -52,7 +53,11 @@ class ShorakaGadaiService if (!$order->kodcaw) { return; } + app(ShorakaNewGadaiService::class)->promote($order); $gadai = $this->gadai($order->kodcaw, $order->norujukan); + if (!$gadai->exists) { + return; + } $sameAmounts = number_format((float) $gadai->pinjaman, 2, '.', '') === $order->order_amount && number_format((float) $gadai->hargajualan, 2, '.', '') === $order->murabaha_amount; $gadai->getConnection()->table('gadai')->where('norujukan', $order->norujukan)->update([ @@ -64,7 +69,7 @@ class ShorakaGadaiService { $gadai = $this->gadai($kodcaw, $norujukan); $order = $this->transaction($kodcaw, $norujukan); - if (!$order || $order->status !== 'Completed' + if (!$gadai->exists || !$order || $order->status !== 'Completed' || number_format((float) $gadai->pinjaman, 2, '.', '') !== $order->order_amount || number_format((float) $gadai->hargajualan, 2, '.', '') !== $order->murabaha_amount) { abort(409, 'Pembiayaan dan SAG ditahan sehingga transaksi Shoraka selesai dan amaun sepadan.'); diff --git a/app/Services/ShorakaNewGadaiService.php b/app/Services/ShorakaNewGadaiService.php new file mode 100644 index 0000000..b7602d4 --- /dev/null +++ b/app/Services/ShorakaNewGadaiService.php @@ -0,0 +1,223 @@ +cache()->get($this->key($branch, $reference)); + } + + private function put(string $branch, string $reference, array $data): void + { + if (!$this->cache()->put($this->key($branch, $reference), $data, self::TTL)) { + abort(503, 'Maklumat sementara tidak dapat disimpan.'); + } + } + + // File cache and these locks must be shared by every API worker serving a branch. + public function locked(string $key, callable $callback) + { + $directory = storage_path('framework/cache/shoraka-locks'); + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { + abort(503, 'Kunci transaksi tidak tersedia.'); + } + $handle = fopen($directory . '/' . hash('sha256', $key) . '.lock', 'c'); + if (!$handle) { + abort(503, 'Kunci transaksi tidak tersedia.'); + } + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + abort(409, 'Transaksi sedang diproses. Sila tunggu.'); + } + try { + return $callback(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + public function reserve(string $branch, array $customer): Gadai + { + return $this->locked('reserve:' . $branch, function () use ($branch, $customer) { + $cawangan = Cawangan::on('mysql7')->where('kodcaw', $branch)->firstOrFail(); + $date = Carbon::now()->toDateString(); + $counter = 'shoraka:serial:' . $branch . ':' . $date; + $latest = Gadai::on($cawangan->mysql)->where('t_gadai', $date)->max('sirig'); + $serial = max((int) $latest, (int) $this->cache()->get($counter, 0)); + do { + ++$serial; + $reference = strtoupper($branch) . Carbon::now()->format('ymd') . '-' . sprintf('%04d', $serial); + } while (ShorakaTransaction::where('norujukan', $reference)->exists() + || Gadai::on($cawangan->mysql)->where('norujukan', $reference)->exists() + || $this->pending($branch, $reference)); + if (!$this->cache()->forever($counter, $serial)) { + abort(503, 'Nombor rujukan tidak dapat ditempah.'); + } + $data = array_merge($customer, [ + 'kodcaw' => $branch, 'norujukan' => $reference, 'sirig' => $serial, + 't_gadai' => $date, 'masa' => Carbon::now()->toTimeString(), 'sttebus' => '', + 'cara_terima' => 'TUNAI', 'cetak' => 0, 'tagserah' => 0, 'tagkomuditi' => 0, + ]); + if ($cawangan->version === 'v2') { + $data['tagbaru'] = 1; + } + $this->put($branch, $reference, ['gadai' => $data, 'items' => [], 'next_item' => 1, 'revision' => 0, 'prepared' => false]); + return $this->find($branch, $reference); + }); + } + + public function abandon(string $branch, string $reference): void + { + $this->locked($branch . ':' . $reference, function () use ($branch, $reference) { + $this->mutable($branch, $reference); + $this->cache()->forget($this->key($branch, $reference)); + }); + } + + public function latestSerial(string $branch, string $date): int + { + return (int) $this->cache()->get('shoraka:serial:' . $branch . ':' . $date, 0); + } + + public function claim(string $branch, string $reference, array $payload, callable $create) + { + return $this->locked($branch . ':' . $reference, function () use ($branch, $reference, $payload, $create) { + $pending = $this->mutable($branch, $reference); + if (!$pending['prepared'] + || number_format((float) ($pending['gadai']['pinjaman'] ?? 0), 2, '.', '') !== $payload['order_amount'] + || number_format((float) ($pending['gadai']['hargajualan'] ?? 0), 2, '.', '') !== $payload['murabaha_amount']) { + abort(409, 'Maklumat gadaian berubah. Sila sahkan semula sebelum penghantaran.'); + } + return $create(); // The unique transaction reference freezes further edits. + }); + } + + public function find(string $branch, string $reference) + { + $cawangan = Cawangan::on('mysql7')->where('kodcaw', $branch)->firstOrFail(); + $saved = Gadai::on($cawangan->mysql)->where('kodcaw', $branch)->where('norujukan', $reference)->first(); + if ($saved) { + return $saved; + } + $pending = $this->pending($branch, $reference); + if (!$pending) { + return null; + } + $model = (new Gadai())->setConnection($cawangan->mysql); + $model->setRawAttributes($pending['gadai']); + return $model; + } + + private function mutable(string $branch, string $reference): array + { + if (ShorakaTransaction::where('norujukan', $reference)->exists()) { + abort(409, 'Kontrak telah dihantar ke Shoraka dan tidak boleh diubah.'); + } + $pending = $this->pending($branch, $reference); + if (!$pending) { + abort(409, 'Maklumat sementara tamat. Semakan diperlukan.'); + } + return $pending; + } + + public function prepare(string $branch, string $reference, array $fields, int $revision): void + { + $this->locked($branch . ':' . $reference, function () use ($branch, $reference, $fields, $revision) { + $pending = $this->mutable($branch, $reference); + if (($pending['revision'] ?? 0) !== $revision) { + abort(409, 'Maklumat marhun berubah semasa pengiraan. Sila sahkan semula.'); + } + $pending['gadai'] = array_merge($pending['gadai'], $fields); + $pending['prepared'] = true; + $this->put($branch, $reference, $pending); + }); + } + + public function items(string $branch, string $reference) + { + return collect(array_values($this->pending($branch, $reference)['items'] ?? [])); + } + + public function changeItem(string $branch, string $reference, array $fields, $id = null, bool $delete = false) + { + return $this->locked($branch . ':' . $reference, function () use ($branch, $reference, $fields, $id, $delete) { + $pending = $this->mutable($branch, $reference); + if ($id !== null && !isset($pending['items'][$id])) { + abort(404, 'Marhun tidak dijumpai.'); + } + if ($id === null) { + $id = $pending['next_item']++; + } + $item = array_merge($pending['items'][$id] ?? [], $fields, ['recno' => (int) $id]); + if ($delete) { + unset($pending['items'][$id]); + } else { + $pending['items'][$id] = $item; + } + $pending['revision'] = ($pending['revision'] ?? 0) + 1; + $pending['prepared'] = false; // Reprice the contract after any collateral edit. + $this->put($branch, $reference, $pending); + return $item; + }); + } + + public function promote(ShorakaTransaction $order): void + { + if ($order->status !== 'Completed' || !$this->pending($order->kodcaw, $order->norujukan)) { + return; + } + $this->locked($order->kodcaw . ':' . $order->norujukan, function () use ($order) { + $pending = $this->pending($order->kodcaw, $order->norujukan); + $data = $pending['gadai']; + if (!$pending['prepared'] + || number_format((float) ($data['pinjaman'] ?? 0), 2, '.', '') !== $order->order_amount + || number_format((float) ($data['hargajualan'] ?? 0), 2, '.', '') !== $order->murabaha_amount) { + abort(409, 'Amaun gadaian tidak sepadan dengan Shoraka.'); + } + $branch = Cawangan::on('mysql7')->where('kodcaw', $order->kodcaw)->firstOrFail(); + $connection = (new Gadai())->setConnection($branch->mysql)->getConnection(); + $connection->transaction(function () use ($connection, $pending, $data, $order) { + $existing = $connection->table('gadai')->where('norujukan', $order->norujukan)->lockForUpdate()->first(); + if ($existing) { + if ($existing->kodcaw !== $order->kodcaw + || number_format((float) $existing->pinjaman, 2, '.', '') !== $order->order_amount + || number_format((float) $existing->hargajualan, 2, '.', '') !== $order->murabaha_amount) { + abort(409, 'Rujukan gadaian bertindih. Semakan diperlukan.'); + } + return; + } + $connection->table('gadai')->insert(array_merge($data, ['tagkomuditi' => 1])); + foreach ($pending['items'] as $item) { + unset($item['recno']); + $connection->table('marhun')->insert($item); + } + }); + \App\Audit::firstOrCreate([ + 'kodcaw' => $order->kodcaw, 'norujukan' => $order->norujukan, 'actions' => 'Daftar Gadai', + ], ['users' => $data['teller'] ?? '', 'new_data' => json_encode($data)]); + // Retain the cache briefly for idempotent reconciliation after a lost response. + }); + } +} diff --git a/app/Services/ShorakaService.php b/app/Services/ShorakaService.php index 4e294f2..dec33cd 100644 --- a/app/Services/ShorakaService.php +++ b/app/Services/ShorakaService.php @@ -32,11 +32,50 @@ class ShorakaService public function configured(): bool { + if ($this->simulationRequested()) { + return $this->simulationAllowed() && in_array($this->simulationMode(), ['failed', 'pending'], true); + } return $this->apiId !== '' && $this->secretKey !== ''; } + protected function simulationAllowed(): bool + { + return app()->environment('local', 'testing'); + } + + protected function simulationMode(): string + { + return strtolower(trim((string) config('services.shoraka.simulation', 'off'))); + } + + protected function simulationRequested(): bool + { + return !in_array($this->simulationMode(), ['', 'off'], true); + } + + private function simulatedResponse(string $orderId): array + { + if (!$this->simulationAllowed()) { + throw new RuntimeException('Shoraka simulation is restricted to local/testing.'); + } + if (strpos($orderId, 'SIM-FAILED-') === 0) { + return ['orderId' => $orderId, 'status' => 'Cancelled']; + } + if (strpos($orderId, 'SIM-PENDING-') === 0) { + return ['orderId' => $orderId, 'status' => 'Active']; + } + throw new RuntimeException('Invalid simulation order ID.'); + } + public function submitOrder(array $data): array { + if ($this->simulationRequested()) { + if (!$this->simulationAllowed() || !in_array($this->simulationMode(), ['failed', 'pending'], true)) { + throw new RuntimeException('Shoraka simulation configuration is not allowed.'); + } + return $this->simulatedResponse('SIM-' . strtoupper($this->simulationMode()) . '-' . bin2hex(random_bytes(12))); + } + $payload = ['api_id' => $this->apiId]; foreach ([ 'product_type', 'commodity_type', 'ownership', 'value_date', @@ -61,6 +100,13 @@ class ShorakaService public function orderStatus(string $orderId): array { + if (strpos($orderId, 'SIM-') === 0) { + return $this->simulatedResponse($orderId); // Never send simulated references to the real provider. + } + if ($this->simulationRequested()) { + throw new RuntimeException('Real order status is unavailable while simulation is enabled.'); + } + return $this->orderResponse($this->request()->post( $this->baseUrl . '/api/orderstatus', $this->orderPayload($orderId) ), $orderId); @@ -68,6 +114,10 @@ class ShorakaService public function certificate(string $orderId): string { + if ($this->simulationRequested() || strpos($orderId, 'SIM-') === 0) { + throw new RuntimeException('Certificates are unavailable for simulation.'); + } + // Section 5 specifies POST, despite GET in the document history. $response = $this->request()->accept('application/pdf')->post( $this->baseUrl . '/api/certificate', $this->orderPayload($orderId) @@ -83,6 +133,10 @@ class ShorakaService public function validCallback(array $payload): bool { + if ($this->simulationRequested() || strpos((string) ($payload['orderId'] ?? ''), 'SIM-') === 0) { + return false; + } + if (!$this->configured() || !isset($payload['apiId'], $payload['signature']) || !is_string($payload['apiId']) diff --git a/app/Support/v1/GadaiV1.php b/app/Support/v1/GadaiV1.php index a35e5d9..fb094c9 100644 --- a/app/Support/v1/GadaiV1.php +++ b/app/Support/v1/GadaiV1.php @@ -73,10 +73,13 @@ class GadaiV1 $old_data = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->first(); } - $bil_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->count(); + $temporary = app(\App\Services\ShorakaNewGadaiService::class); + $temporaryData = $temporary->pending($request->kodcaw, $norujukan); + $isTemporary = $temporaryData !== null; + $bil_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->count() : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->count()); - $total_nilai_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun'); - $total_berat = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat'); + $total_nilai_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('n_marhun') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun')); + $total_berat = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('berat') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat')); $kadar_raw = KadarUpah::where('min','<=',$total_nilai_marhun)->where('max','>=',$total_nilai_marhun)->first(); $tawarruqservices = new Calculation(); @@ -88,9 +91,7 @@ class GadaiV1 $hargajualan = $request->pinjaman + $upah12; - $gadai = Gadai::on($cawangan['mysql']) - ->where('norujukan','=',$norujukan) - ->update(array( + $gadaiFields = array( 'nilaimarhun' => $total_nilai_marhun, 'berat'=> $total_berat, 'pinjaman' => $request->pinjaman, @@ -115,7 +116,12 @@ class GadaiV1 'upahdibayar' => 0.00, 'tempohbayar' => 0.00, 'lelong_tawarruq' => 1 - )); + ); + if ($isTemporary) { + $temporary->prepare($request->kodcaw, $norujukan, $gadaiFields, $temporaryData['revision'] ?? 0); + return response()->json(true); + } + $gadai = Gadai::on($cawangan['mysql'])->where('norujukan', $norujukan)->update($gadaiFields); $new_data = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->first(); diff --git a/app/Support/v1/Penebusan.php b/app/Support/v1/Penebusan.php index 08af1c5..e0a864a 100644 --- a/app/Support/v1/Penebusan.php +++ b/app/Support/v1/Penebusan.php @@ -946,7 +946,8 @@ class Penebusan $gadaiTransaction = Gadai::on($cawangan['mysql'])->where('t_gadai','=',$current->toDateString())->orderBy('sirig', 'desc')->first(); $gadaiTransaction = optional($gadaiTransaction)->toArray(); - $new_sirig = ($gadaiTransaction) ? $gadaiTransaction['sirig'] + 1 : 1; + $new_sirig = max((int) ($gadaiTransaction['sirig'] ?? 0), + app(\App\Services\ShorakaNewGadaiService::class)->latestSerial($kodcaw, $current->toDateString())) + 1; $curent_year = substr($current->year,2,2); $curent_month = sprintf("%02d", $current->month); $current_day = sprintf("%02d", $current->day); diff --git a/app/Support/v2/GadaiV2.php b/app/Support/v2/GadaiV2.php index 7c17ca5..fa40cf5 100644 --- a/app/Support/v2/GadaiV2.php +++ b/app/Support/v2/GadaiV2.php @@ -107,10 +107,13 @@ class GadaiV2 $old_data = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->first(); } - $bil_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('bil'); + $temporary = app(\App\Services\ShorakaNewGadaiService::class); + $temporaryData = $temporary->pending($request->kodcaw, $norujukan); + $isTemporary = $temporaryData !== null; + $bil_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('bil') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('bil')); - $total_nilai_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun'); - $total_berat = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat'); + $total_nilai_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('n_marhun') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun')); + $total_berat = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('berat') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat')); $kadar_raw = $this->getKadarUpahAnggota($request->nokp, $total_nilai_marhun, $cawangan['group_id']); @@ -190,6 +193,10 @@ if (is_null($kadar_raw)) { } } + if ($isTemporary) { + $temporary->prepare($request->kodcaw, $norujukan, $gadaiFields, $temporaryData['revision'] ?? 0); + return 'true'; + } $gadai = Gadai::on($cawangan['mysql']) ->where('norujukan','=',$norujukan) ->update($gadaiFields); diff --git a/app/Support/v2/Penebusan.php b/app/Support/v2/Penebusan.php index 5a3f71b..c3a47cc 100644 --- a/app/Support/v2/Penebusan.php +++ b/app/Support/v2/Penebusan.php @@ -1224,7 +1224,8 @@ class Penebusan $gadaiTransaction = Gadai::on($cawangan['mysql'])->where('t_gadai','=',$current->toDateString())->orderBy('sirig', 'desc')->first(); $gadaiTransaction = optional($gadaiTransaction)->toArray(); - $new_sirig = ($gadaiTransaction) ? $gadaiTransaction['sirig'] + 1 : 1; + $new_sirig = max((int) ($gadaiTransaction['sirig'] ?? 0), + app(\App\Services\ShorakaNewGadaiService::class)->latestSerial($kodcaw, $current->toDateString())) + 1; $curent_year = substr($current->year,2,2); $curent_month = sprintf("%02d", $current->month); $current_day = sprintf("%02d", $current->day); diff --git a/config/services.php b/config/services.php index cabce3f..d0db227 100644 --- a/config/services.php +++ b/config/services.php @@ -3,6 +3,8 @@ return [ 'shoraka' => [ + // Local UI tests only: off, failed or pending. Success is deliberately excluded. + 'simulation' => env('SHORAKA_SIMULATION', 'off'), 'base_url' => env( 'SHORAKA_BASE_URL', 'https://uat2.shorakagroup.com' -- 2.52.0 From ac57e04eac53b4e8cb23b155e9684f11f71fdc4d Mon Sep 17 00:00:00 2001 From: afrina Date: Mon, 19 Oct 2026 13:31:06 +0800 Subject: [PATCH 5/8] refactor(shoraka): move financing records to master database --- ...create_master_shoraka_financings_table.php | 28 +++++++++++++++++++ docker/php/Dockerfile | 3 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php diff --git a/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php b/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php new file mode 100644 index 0000000..06419e7 --- /dev/null +++ b/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php @@ -0,0 +1,28 @@ +create('shoraka_financings', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('kodcaw', 20); + $table->string('norujukan'); + $table->string('teller'); + $table->string('action', 30); + $table->string('state', 30)->default('WAITING'); + $table->json('context'); + $table->timestamps(); + $table->unique(['kodcaw', 'norujukan']); + }); + } + + public function down() + { + Schema::connection('mysql7')->dropIfExists('shoraka_financings'); + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 0d25483..8479d61 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -39,7 +39,8 @@ COPY docker/php/certs/*.crt /usr/local/share/ca-certificates/ RUN update-ca-certificates \ && chmod +x /usr/local/bin/app-entrypoint.sh \ - && usermod -u 1000 www-data && groupmod -g 1000 www-data + && usermod -u 1000 www-data && groupmod -g 1000 www-data \ + && chown www-data:www-data /usr/local/etc/php/conf.d/app.ini ENTRYPOINT ["app-entrypoint.sh"] CMD ["php-fpm"] -- 2.52.0 From 4648c7072b7645a1e47d2b67519a697c8e0f42e1 Mon Sep 17 00:00:00 2001 From: afrina Date: Wed, 21 Oct 2026 10:32:54 +0800 Subject: [PATCH 6/8] fix(shoraka): reuse batal gadai norujukan --- app/Services/ShorakaNewGadaiService.php | 58 ++++++++++++++++++++----- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/app/Services/ShorakaNewGadaiService.php b/app/Services/ShorakaNewGadaiService.php index b7602d4..7c7d4bf 100644 --- a/app/Services/ShorakaNewGadaiService.php +++ b/app/Services/ShorakaNewGadaiService.php @@ -65,14 +65,35 @@ class ShorakaNewGadaiService $date = Carbon::now()->toDateString(); $counter = 'shoraka:serial:' . $branch . ':' . $date; $latest = Gadai::on($cawangan->mysql)->where('t_gadai', $date)->max('sirig'); - $serial = max((int) $latest, (int) $this->cache()->get($counter, 0)); - do { - ++$serial; - $reference = strtoupper($branch) . Carbon::now()->format('ymd') . '-' . sprintf('%04d', $serial); - } while (ShorakaTransaction::where('norujukan', $reference)->exists() - || Gadai::on($cawangan->mysql)->where('norujukan', $reference)->exists() - || $this->pending($branch, $reference)); - if (!$this->cache()->forever($counter, $serial)) { + $highest = max((int) $latest, (int) $this->cache()->get($counter, 0)); + $releasedKey = 'shoraka:released:' . $branch . ':' . $date; + $released = $this->cache()->get($releasedKey, []); + sort($released, SORT_NUMERIC); + $serial = null; + while ($released) { + $candidate = (int) array_shift($released); + $candidateReference = strtoupper($branch) . Carbon::parse($date)->format('ymd') . '-' . sprintf('%04d', $candidate); + if (!ShorakaTransaction::where('norujukan', $candidateReference)->exists() + && !Gadai::on($cawangan->mysql)->where('norujukan', $candidateReference)->exists() + && !$this->pending($branch, $candidateReference)) { + $serial = $candidate; + $reference = $candidateReference; + break; + } + } + if (!$this->cache()->forever($releasedKey, $released)) { + abort(503, 'Nombor rujukan dibatalkan tidak dapat dikemas kini.'); + } + if ($serial === null) { + $serial = $highest; + do { + ++$serial; + $reference = strtoupper($branch) . Carbon::parse($date)->format('ymd') . '-' . sprintf('%04d', $serial); + } while (ShorakaTransaction::where('norujukan', $reference)->exists() + || Gadai::on($cawangan->mysql)->where('norujukan', $reference)->exists() + || $this->pending($branch, $reference)); + } + if (!$this->cache()->forever($counter, max($highest, $serial))) { abort(503, 'Nombor rujukan tidak dapat ditempah.'); } $data = array_merge($customer, [ @@ -90,9 +111,24 @@ class ShorakaNewGadaiService public function abandon(string $branch, string $reference): void { - $this->locked($branch . ':' . $reference, function () use ($branch, $reference) { - $this->mutable($branch, $reference); - $this->cache()->forget($this->key($branch, $reference)); + // Use the reservation lock as well so release and allocation cannot race. + $this->locked('reserve:' . $branch, function () use ($branch, $reference) { + $this->locked($branch . ':' . $reference, function () use ($branch, $reference) { + $pending = $this->mutable($branch, $reference); + $cawangan = Cawangan::on('mysql7')->where('kodcaw', $branch)->firstOrFail(); + if (Gadai::on($cawangan->mysql)->where('norujukan', $reference)->exists()) { + abort(409, 'Gadaian telah disimpan dan nombor rujukan tidak boleh digunakan semula.'); + } + $releasedKey = 'shoraka:released:' . $branch . ':' . $pending['gadai']['t_gadai']; + $released = $this->cache()->get($releasedKey, []); + $released[] = (int) $pending['gadai']['sirig']; + if (!$this->cache()->forever($releasedKey, array_values(array_unique($released)))) { + abort(503, 'Nombor rujukan tidak dapat dilepaskan.'); + } + if (!$this->cache()->forget($this->key($branch, $reference))) { + abort(503, 'Maklumat sementara tidak dapat dibatalkan.'); + } + }); }); } -- 2.52.0 From e3e7694b54160177291dc3b7be8a16be553a9d20 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 26 Oct 2026 12:49:57 +0800 Subject: [PATCH 7/8] DONE: remove cache, and remove dto financing --- app/Http/Controllers/GadaiController.php | 47 +--- app/Http/Controllers/MarhunController.php | 42 +-- app/Http/Controllers/ShorakaController.php | 9 +- .../ShorakaFinancingController.php | 168 ++++++++++++ .../Controllers/ShorakaGadaiController.php | 16 +- app/Services/ShorakaGadaiService.php | 3 +- app/Services/ShorakaNewGadaiService.php | 259 ------------------ app/ShorakaFinancing.php | 16 ++ app/Support/v1/GadaiV1.php | 13 +- app/Support/v1/Penebusan.php | 3 +- app/Support/v2/GadaiV2.php | 13 +- app/Support/v2/Penebusan.php | 3 +- routes/web.php | 4 + 13 files changed, 216 insertions(+), 380 deletions(-) create mode 100644 app/Http/Controllers/ShorakaFinancingController.php delete mode 100644 app/Services/ShorakaNewGadaiService.php create mode 100644 app/ShorakaFinancing.php diff --git a/app/Http/Controllers/GadaiController.php b/app/Http/Controllers/GadaiController.php index 9d795c6..1894cdd 100644 --- a/app/Http/Controllers/GadaiController.php +++ b/app/Http/Controllers/GadaiController.php @@ -55,10 +55,6 @@ class GadaiController extends Controller $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$cawangan_code)->first(); $gadaiTransaction = Gadai::on($cawangan['mysql'])->where('t_gadai','=',$date)->orderBy('sirig', 'desc')->first(); - $serial = app(\App\Services\ShorakaNewGadaiService::class)->latestSerial($cawangan_code, $date); - if ($serial > (int) ($gadaiTransaction->sirig ?? 0)) { - $gadaiTransaction = ['sirig' => $serial]; - } }catch(\Exception $e){ Log::debug($e); Log::debug($cawangan_code); @@ -115,7 +111,7 @@ class GadaiController extends Controller $gadai_all = []; foreach($cawangan_all as $index=>$cawangan){ - $gadai = collect(array_filter([app(\App\Services\ShorakaNewGadaiService::class)->find($cawangan->kodcaw, $norujukan)])); + $gadai = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); if(sizeof($gadai) != 0){ array_push($gadai_all,$gadai); } @@ -134,7 +130,7 @@ class GadaiController extends Controller $gadai_all = []; foreach($cawangan_all as $index=>$cawangan){ - $gadai = collect(array_filter([app(\App\Services\ShorakaNewGadaiService::class)->find($cawangan->kodcaw, $norujukan)])); + $gadai = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); if(sizeof($gadai) != 0){ array_push($gadai_all,$gadai); } @@ -156,22 +152,12 @@ class GadaiController extends Controller public function showOneGadaibyRujukanAndCawangan($kodcaw,$norujukan){ $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); - $gadai = collect(array_filter([app(\App\Services\ShorakaNewGadaiService::class)->find($cawangan->kodcaw, $norujukan)])); + $gadai = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); return response()->json($gadai); } public function create($kodcaw,Request $request){ - if ($request->boolean('shoraka_new')) { - $this->validate($request, ['nokp' => 'required|string', 'nopelanggan' => 'required']); - return response()->json(app(\App\Services\ShorakaNewGadaiService::class)->reserve( - $kodcaw, $request->only(['nokp', 'nopelanggan']) - ), 201); - } - - if (app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, (string) $request->norujukan)) { - abort(409, 'Nombor rujukan sedang digunakan. Sila dapatkan nombor baharu.'); - } $master = new MasterServices($kodcaw); $response = $master->createGadai($request); @@ -246,10 +232,6 @@ class GadaiController extends Controller if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { abort(409, 'Kontrak telah dihantar ke Shoraka dan tidak boleh diubah.'); } - $temporary = app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan); - if ($temporary && (string) $temporary['gadai']['nokp'] !== (string) $request->nokp) { - abort(409, 'Pelanggan tidak sepadan dengan borang gadaian sementara.'); - } $request->merge(['kodcaw' => $kodcaw, 'norujukan' => $norujukan]); $master = new MasterServices($kodcaw); $response = $master->createMarhunGadai($norujukan, $request); @@ -266,13 +248,7 @@ class GadaiController extends Controller public function checkNorujukan(Request $request){ $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$request->kodcaw)->first(); - $gadai = app(\App\Services\ShorakaNewGadaiService::class)->find($request->kodcaw, $request->norujukan); - if (!$gadai && \App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) { - abort(409, 'Transaksi Shoraka wujud tetapi maklumat sementara tiada. Hubungi operasi; jangan daftar semula.'); - } - if ($gadai && $gadai->nokp !== $request->nokp) { - $gadai = null; - } + $gadai = Gadai::on($cawangan['mysql'])->where([['norujukan','=',$request->norujukan],['nokp','=',$request->nokp]])->first(); return response()->json($gadai); } @@ -395,16 +371,9 @@ class GadaiController extends Controller } public function batalGadai($kodcaw, $norujukan,Request $request){ - $temporary = app(\App\Services\ShorakaNewGadaiService::class); - $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); - if ($order && !empty($order->request_payload['_new_gadai'])) { + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { abort(409, 'Transaksi Shoraka telah dihantar. Semakan operasi diperlukan sebelum pembatalan.'); } - if ($temporary->pending($kodcaw, $norujukan)) { - $temporary->abandon($kodcaw, $norujukan); - return response('Batal Berjaya', 200); - } - $current = Carbon::now(); $current = new Carbon(); @@ -459,8 +428,7 @@ class GadaiController extends Controller } public function cetakSAG($kodcaw,$norujukan,Request $request){ - if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists() - || app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan)) { + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan); } @@ -488,8 +456,7 @@ class GadaiController extends Controller } public function serahMarhun($kodcaw,$norujukan){ - if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists() - || app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan)) { + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan); } diff --git a/app/Http/Controllers/MarhunController.php b/app/Http/Controllers/MarhunController.php index 2df9169..38b94db 100644 --- a/app/Http/Controllers/MarhunController.php +++ b/app/Http/Controllers/MarhunController.php @@ -26,9 +26,6 @@ class MarhunController extends Controller $marhun_all = []; foreach($cawangan_all as $index=>$cawangan){ $marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->get(); - if ($marhun->isEmpty()) { - $marhun = app(\App\Services\ShorakaNewGadaiService::class)->items($cawangan->kodcaw, $norujukan); - } if(sizeof($marhun) != 0){ array_push($marhun_all,$marhun); } @@ -43,9 +40,6 @@ class MarhunController extends Controller $marhun = Marhun::on($cawangan['mysql'])->where('norujukan',$request->norujukan)->distinct()->pluck('karat')->toArray(); - if (app(\App\Services\ShorakaNewGadaiService::class)->pending($request->kodcaw, $request->norujukan)) { - $marhun = app(\App\Services\ShorakaNewGadaiService::class)->items($request->kodcaw, $request->norujukan)->pluck('karat')->unique()->all(); - } $mutu = HargaEmas::on('mysql7')->whereIn('karat',$marhun)->pluck('keterangan')->toArray(); return response()->json($mutu); @@ -66,7 +60,7 @@ class MarhunController extends Controller public function create($kodcaw,Request $request) { $order = \App\ShorakaTransaction::where('norujukan', $request->norujukan)->first(); - if ($order && !empty($order->request_payload['_new_gadai'])) { + if ($order) { abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); } @@ -107,10 +101,6 @@ class MarhunController extends Controller }; $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); - $temporary = app(\App\Services\ShorakaNewGadaiService::class); - if ($temporary->pending($kodcaw, $request->norujukan)) { - return response()->json($temporary->changeItem($kodcaw, $request->norujukan, $marhunToCreate), 201); - } $marhun = Marhun::on($cawangan['mysql'])->create($marhunToCreate); $audit = new Audit(); @@ -143,17 +133,10 @@ class MarhunController extends Controller public function deletemarhun($kodcaw,$norujukan,Request $request) { $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); - if ($order && !empty($order->request_payload['_new_gadai'])) { + if ($order) { abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); } - $temporary = app(\App\Services\ShorakaNewGadaiService::class); - if ($temporary->pending($kodcaw, $norujukan)) { - $temporary->changeItem($kodcaw, $norujukan, [], $request->recno, true); - return response('Deleted Successfully', 200); - } - - $current = Carbon::now(); $current = new Carbon(); @@ -179,7 +162,7 @@ class MarhunController extends Controller public function editmarhun($kodcaw,$norujukan,Request $request) { $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); - if ($order && !empty($order->request_payload['_new_gadai'])) { + if ($order) { abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); } @@ -190,17 +173,6 @@ class MarhunController extends Controller $harga = HargaEmas::on('mysql7')->where('karat',$request->karat)->first(); $nilai_marhun = $request->berat * $harga['harga']; - $temporary = app(\App\Services\ShorakaNewGadaiService::class); - if ($temporary->pending($kodcaw, $norujukan)) { - $temporary->changeItem($kodcaw, $norujukan, [ - 'karat' => $request->karat, 'marhun' => $request->marhun, 'nota' => $request->nota, - 'tag_permata' => $request->tag_permata, - 'berat_batu_permata' => $request->berat_batu_permata ?: null, - 'berat' => $request->berat, 'harga' => $harga['harga'], 'n_marhun' => $nilai_marhun, - 'bil' => $request->kuantiti, - ], $request->recno); - return response()->json(true); - } $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); // Lock the parent first so concurrent edits to this SAG are serialized. @@ -299,9 +271,6 @@ class MarhunController extends Controller $marhuns = Marhun::on($cawangan['mysql'])->where('norujukan',$request->norujukan)->get(); - if ($marhuns->isEmpty()) { - $marhuns = app(\App\Services\ShorakaNewGadaiService::class)->items($request->kodcaw, $request->norujukan); - } $totalnilaimarhun = 0; foreach($marhuns as $index=>$marhun){ @@ -315,11 +284,6 @@ class MarhunController extends Controller public function getMarhunCaw($kodcaw,$norujukan) { - $temporary = app(\App\Services\ShorakaNewGadaiService::class); - if ($temporary->pending($kodcaw, $norujukan)) { - return response()->json($temporary->items($kodcaw, $norujukan)->sum('n_marhun')); - } - $cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first(); $data = Marhun::on($cawangan['mysql']) ->where('norujukan','=', $norujukan) diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php index 4f19c01..4f5e6e0 100644 --- a/app/Http/Controllers/ShorakaController.php +++ b/app/Http/Controllers/ShorakaController.php @@ -58,15 +58,10 @@ class ShorakaController extends Controller 'ownership_doc_no' => $payload['ownership_doc_no'], 'order_amount' => $payload['order_amount'], 'murabaha_amount' => $payload['murabaha_amount'], - 'request_payload' => $request->attributes->get('shoraka_new') - ? array_merge($payload, ['_new_gadai' => true]) : $payload, + 'request_payload' => $payload, 'attempt_count' => 1, ]; - $create = function () use ($attributes) { return ShorakaTransaction::create($attributes); }; - $transaction = $request->attributes->get('shoraka_new') - ? app(\App\Services\ShorakaNewGadaiService::class)->claim( - $request->attributes->get('shoraka_kodcaw'), $validated['norujukan'], $payload, $create - ) : $create(); + $transaction = ShorakaTransaction::create($attributes); } catch (QueryException $exception) { $existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first(); if ($existing) { diff --git a/app/Http/Controllers/ShorakaFinancingController.php b/app/Http/Controllers/ShorakaFinancingController.php new file mode 100644 index 0000000..32b63c7 --- /dev/null +++ b/app/Http/Controllers/ShorakaFinancingController.php @@ -0,0 +1,168 @@ +validate($request, [ + 'kodcaw' => 'nullable|string|max:30', + 'norujukan' => 'nullable|string|max:100', + 'state' => 'nullable|in:WAITING,PROCESSING,REVIEW,DONE', + 'date_from' => 'nullable|date_format:Y-m-d', + 'date_to' => 'nullable|date_format:Y-m-d', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + 'branch_scope' => 'nullable|string|max:30', + ]); + + try { + $query = ShorakaFinancing::query(); + foreach (['kodcaw', 'norujukan', 'state'] as $field) { + if ($request->filled($field)) { + $query->where($field, $request->input($field)); + } + } + if ($request->filled('date_from')) { + $query->where('created_at', '>=', $request->input('date_from') . ' 00:00:00'); + } + if ($request->filled('date_to')) { + $query->where('created_at', '<', Carbon::parse($request->input('date_to'))->addDay()->format('Y-m-d') . ' 00:00:00'); + } + $records = $query->orderByDesc('created_at')->orderByDesc('id') + ->paginate(min(100, max(1, (int) $request->input('per_page', 25)))); + $payload = [ + 'data' => $records->getCollection()->map(function (ShorakaFinancing $record) { + return $this->present($record, false); + })->values(), + 'total' => $records->total(), + 'current_page' => $records->currentPage(), + 'per_page' => $records->perPage(), + 'last_page' => $records->lastPage(), + ]; + if ($request->input('with_branches')) { + $branches = ShorakaFinancing::query(); + if ($request->filled('branch_scope')) { + $branches->where('kodcaw', $request->input('branch_scope')); + } + $payload['branches'] = $branches->distinct()->orderBy('kodcaw')->pluck('kodcaw')->values(); + } + return response()->json($payload); + } catch (QueryException $exception) { + return $this->unavailable(); + } + } + + public function show(string $kodcaw, string $norujukan) + { + try { + $record = $this->find($kodcaw, $norujukan); + } catch (QueryException $exception) { + return $this->unavailable(); + } + if (!$record) { + return response()->json(['message' => 'Rekod pembiayaan tidak dijumpai.'], 404); + } + return response()->json(['data' => $this->present($record)]); + } + + public function store(string $kodcaw, string $norujukan, Request $request) + { + $this->validate($request, [ + 'teller' => 'required|string|max:255', + 'action' => 'required|string|max:30', + 'context' => 'present|array', + ]); + try { + $existing = $this->find($kodcaw, $norujukan); + if ($existing) { + return response()->json(['created' => false, 'data' => $this->present($existing)]); + } + $record = ShorakaFinancing::create([ + 'kodcaw' => $kodcaw, + 'norujukan' => $norujukan, + 'teller' => $request->input('teller'), + 'action' => $request->input('action'), + 'state' => 'WAITING', + 'context' => $request->input('context'), + ]); + return response()->json(['created' => true, 'data' => $this->present($record)], 201); + } catch (QueryException $exception) { + try { + $existing = $this->find($kodcaw, $norujukan); + } catch (QueryException $again) { + return $this->unavailable(); + } + if ($existing) { + return response()->json(['created' => false, 'data' => $this->present($existing)]); + } + return $this->unavailable(); + } + } + + public function update(string $kodcaw, string $norujukan, Request $request) + { + $this->validate($request, [ + 'state' => 'required|in:WAITING,PROCESSING,REVIEW,DONE', + 'expected_state' => 'nullable|in:WAITING,PROCESSING,REVIEW,DONE', + 'context' => 'nullable|array', + ]); + try { + $record = $this->find($kodcaw, $norujukan); + if (!$record) { + return response()->json(['message' => 'Rekod pembiayaan tidak dijumpai.'], 404); + } + if ($request->filled('expected_state')) { + $claimed = ShorakaFinancing::where('id', $record->id) + ->where('state', $request->input('expected_state')) + ->update(['state' => $request->input('state'), 'updated_at' => Carbon::now()]); + return response()->json([ + 'claimed' => $claimed === 1, + 'data' => $this->present($record->fresh()), + ]); + } + $fields = ['state' => $request->input('state')]; + if ($request->exists('context')) { + $fields['context'] = $request->input('context') ?? []; + } + $record->update($fields); + return response()->json(['claimed' => true, 'data' => $this->present($record->fresh())]); + } catch (QueryException $exception) { + return $this->unavailable(); + } + } + + private function find(string $kodcaw, string $norujukan): ?ShorakaFinancing + { + return ShorakaFinancing::where('kodcaw', $kodcaw)->where('norujukan', $norujukan)->first(); + } + + private function present(ShorakaFinancing $record, bool $withContext = true): array + { + $data = [ + 'id' => $record->id, + 'kodcaw' => $record->kodcaw, + 'norujukan' => $record->norujukan, + 'teller' => $record->teller, + 'action' => $record->action, + 'state' => $record->state, + 'created_at' => optional($record->created_at)->toDateTimeString(), + 'updated_at' => optional($record->updated_at)->toDateTimeString(), + ]; + if ($withContext) { + $data['context'] = $record->context ?? []; + } + return $data; + } + + private function unavailable() + { + return response()->json(['message' => 'Rekod pembiayaan Shoraka tidak tersedia.'], 503); + } +} diff --git a/app/Http/Controllers/ShorakaGadaiController.php b/app/Http/Controllers/ShorakaGadaiController.php index 990be02..ab9ac86 100644 --- a/app/Http/Controllers/ShorakaGadaiController.php +++ b/app/Http/Controllers/ShorakaGadaiController.php @@ -11,7 +11,13 @@ class ShorakaGadaiController extends ShorakaController { public function readiness(ShorakaService $shoraka) { - return response()->json(['provider' => 'shoraka', 'ready' => $shoraka->configured()]); + try { + \App\ShorakaFinancing::query()->limit(1)->exists(); + $storeReady = true; + } catch (\Throwable $exception) { + $storeReady = false; + } + return response()->json(['provider' => 'shoraka', 'ready' => $storeReady && $shoraka->configured()]); } public function showGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService) @@ -45,16 +51,8 @@ class ShorakaGadaiController extends ShorakaController || $gadai->cetak || $gadai->sttebus) { abort(409, 'Gadaian lama atau telah selesai tidak boleh dihantar sebagai order baharu.'); } - $temporary = app(\App\Services\ShorakaNewGadaiService::class)->pending($kodcaw, $norujukan); - if ($temporary && $gadai->exists) { - abort(409, 'Rujukan sementara bertindih dengan gadaian sedia ada. Semakan operasi diperlukan.'); - } - if ($temporary && !$temporary['prepared']) { - abort(409, 'Sila sahkan semula amaun gadaian selepas perubahan marhun.'); - } $request = new Request($gadaiService->payload($gadai)); $request->attributes->set('shoraka_kodcaw', $kodcaw); - $request->attributes->set('shoraka_new', !$gadai->exists); return $this->submit($request, $shoraka); } diff --git a/app/Services/ShorakaGadaiService.php b/app/Services/ShorakaGadaiService.php index 027ebd9..61d1b14 100644 --- a/app/Services/ShorakaGadaiService.php +++ b/app/Services/ShorakaGadaiService.php @@ -13,7 +13,7 @@ class ShorakaGadaiService public function gadai(string $kodcaw, string $norujukan): Gadai { $branch = Cawangan::on('mysql7')->where('kodcaw', $kodcaw)->firstOrFail(); - return app(ShorakaNewGadaiService::class)->find($kodcaw, $norujukan) + return Gadai::on($branch->mysql)->where('kodcaw', $kodcaw)->where('norujukan', $norujukan)->first() ?? abort(404, 'Maklumat gadaian tidak tersedia. Semakan diperlukan.'); } @@ -53,7 +53,6 @@ class ShorakaGadaiService if (!$order->kodcaw) { return; } - app(ShorakaNewGadaiService::class)->promote($order); $gadai = $this->gadai($order->kodcaw, $order->norujukan); if (!$gadai->exists) { return; diff --git a/app/Services/ShorakaNewGadaiService.php b/app/Services/ShorakaNewGadaiService.php deleted file mode 100644 index 7c7d4bf..0000000 --- a/app/Services/ShorakaNewGadaiService.php +++ /dev/null @@ -1,259 +0,0 @@ -cache()->get($this->key($branch, $reference)); - } - - private function put(string $branch, string $reference, array $data): void - { - if (!$this->cache()->put($this->key($branch, $reference), $data, self::TTL)) { - abort(503, 'Maklumat sementara tidak dapat disimpan.'); - } - } - - // File cache and these locks must be shared by every API worker serving a branch. - public function locked(string $key, callable $callback) - { - $directory = storage_path('framework/cache/shoraka-locks'); - if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { - abort(503, 'Kunci transaksi tidak tersedia.'); - } - $handle = fopen($directory . '/' . hash('sha256', $key) . '.lock', 'c'); - if (!$handle) { - abort(503, 'Kunci transaksi tidak tersedia.'); - } - if (!flock($handle, LOCK_EX | LOCK_NB)) { - fclose($handle); - abort(409, 'Transaksi sedang diproses. Sila tunggu.'); - } - try { - return $callback(); - } finally { - flock($handle, LOCK_UN); - fclose($handle); - } - } - - public function reserve(string $branch, array $customer): Gadai - { - return $this->locked('reserve:' . $branch, function () use ($branch, $customer) { - $cawangan = Cawangan::on('mysql7')->where('kodcaw', $branch)->firstOrFail(); - $date = Carbon::now()->toDateString(); - $counter = 'shoraka:serial:' . $branch . ':' . $date; - $latest = Gadai::on($cawangan->mysql)->where('t_gadai', $date)->max('sirig'); - $highest = max((int) $latest, (int) $this->cache()->get($counter, 0)); - $releasedKey = 'shoraka:released:' . $branch . ':' . $date; - $released = $this->cache()->get($releasedKey, []); - sort($released, SORT_NUMERIC); - $serial = null; - while ($released) { - $candidate = (int) array_shift($released); - $candidateReference = strtoupper($branch) . Carbon::parse($date)->format('ymd') . '-' . sprintf('%04d', $candidate); - if (!ShorakaTransaction::where('norujukan', $candidateReference)->exists() - && !Gadai::on($cawangan->mysql)->where('norujukan', $candidateReference)->exists() - && !$this->pending($branch, $candidateReference)) { - $serial = $candidate; - $reference = $candidateReference; - break; - } - } - if (!$this->cache()->forever($releasedKey, $released)) { - abort(503, 'Nombor rujukan dibatalkan tidak dapat dikemas kini.'); - } - if ($serial === null) { - $serial = $highest; - do { - ++$serial; - $reference = strtoupper($branch) . Carbon::parse($date)->format('ymd') . '-' . sprintf('%04d', $serial); - } while (ShorakaTransaction::where('norujukan', $reference)->exists() - || Gadai::on($cawangan->mysql)->where('norujukan', $reference)->exists() - || $this->pending($branch, $reference)); - } - if (!$this->cache()->forever($counter, max($highest, $serial))) { - abort(503, 'Nombor rujukan tidak dapat ditempah.'); - } - $data = array_merge($customer, [ - 'kodcaw' => $branch, 'norujukan' => $reference, 'sirig' => $serial, - 't_gadai' => $date, 'masa' => Carbon::now()->toTimeString(), 'sttebus' => '', - 'cara_terima' => 'TUNAI', 'cetak' => 0, 'tagserah' => 0, 'tagkomuditi' => 0, - ]); - if ($cawangan->version === 'v2') { - $data['tagbaru'] = 1; - } - $this->put($branch, $reference, ['gadai' => $data, 'items' => [], 'next_item' => 1, 'revision' => 0, 'prepared' => false]); - return $this->find($branch, $reference); - }); - } - - public function abandon(string $branch, string $reference): void - { - // Use the reservation lock as well so release and allocation cannot race. - $this->locked('reserve:' . $branch, function () use ($branch, $reference) { - $this->locked($branch . ':' . $reference, function () use ($branch, $reference) { - $pending = $this->mutable($branch, $reference); - $cawangan = Cawangan::on('mysql7')->where('kodcaw', $branch)->firstOrFail(); - if (Gadai::on($cawangan->mysql)->where('norujukan', $reference)->exists()) { - abort(409, 'Gadaian telah disimpan dan nombor rujukan tidak boleh digunakan semula.'); - } - $releasedKey = 'shoraka:released:' . $branch . ':' . $pending['gadai']['t_gadai']; - $released = $this->cache()->get($releasedKey, []); - $released[] = (int) $pending['gadai']['sirig']; - if (!$this->cache()->forever($releasedKey, array_values(array_unique($released)))) { - abort(503, 'Nombor rujukan tidak dapat dilepaskan.'); - } - if (!$this->cache()->forget($this->key($branch, $reference))) { - abort(503, 'Maklumat sementara tidak dapat dibatalkan.'); - } - }); - }); - } - - public function latestSerial(string $branch, string $date): int - { - return (int) $this->cache()->get('shoraka:serial:' . $branch . ':' . $date, 0); - } - - public function claim(string $branch, string $reference, array $payload, callable $create) - { - return $this->locked($branch . ':' . $reference, function () use ($branch, $reference, $payload, $create) { - $pending = $this->mutable($branch, $reference); - if (!$pending['prepared'] - || number_format((float) ($pending['gadai']['pinjaman'] ?? 0), 2, '.', '') !== $payload['order_amount'] - || number_format((float) ($pending['gadai']['hargajualan'] ?? 0), 2, '.', '') !== $payload['murabaha_amount']) { - abort(409, 'Maklumat gadaian berubah. Sila sahkan semula sebelum penghantaran.'); - } - return $create(); // The unique transaction reference freezes further edits. - }); - } - - public function find(string $branch, string $reference) - { - $cawangan = Cawangan::on('mysql7')->where('kodcaw', $branch)->firstOrFail(); - $saved = Gadai::on($cawangan->mysql)->where('kodcaw', $branch)->where('norujukan', $reference)->first(); - if ($saved) { - return $saved; - } - $pending = $this->pending($branch, $reference); - if (!$pending) { - return null; - } - $model = (new Gadai())->setConnection($cawangan->mysql); - $model->setRawAttributes($pending['gadai']); - return $model; - } - - private function mutable(string $branch, string $reference): array - { - if (ShorakaTransaction::where('norujukan', $reference)->exists()) { - abort(409, 'Kontrak telah dihantar ke Shoraka dan tidak boleh diubah.'); - } - $pending = $this->pending($branch, $reference); - if (!$pending) { - abort(409, 'Maklumat sementara tamat. Semakan diperlukan.'); - } - return $pending; - } - - public function prepare(string $branch, string $reference, array $fields, int $revision): void - { - $this->locked($branch . ':' . $reference, function () use ($branch, $reference, $fields, $revision) { - $pending = $this->mutable($branch, $reference); - if (($pending['revision'] ?? 0) !== $revision) { - abort(409, 'Maklumat marhun berubah semasa pengiraan. Sila sahkan semula.'); - } - $pending['gadai'] = array_merge($pending['gadai'], $fields); - $pending['prepared'] = true; - $this->put($branch, $reference, $pending); - }); - } - - public function items(string $branch, string $reference) - { - return collect(array_values($this->pending($branch, $reference)['items'] ?? [])); - } - - public function changeItem(string $branch, string $reference, array $fields, $id = null, bool $delete = false) - { - return $this->locked($branch . ':' . $reference, function () use ($branch, $reference, $fields, $id, $delete) { - $pending = $this->mutable($branch, $reference); - if ($id !== null && !isset($pending['items'][$id])) { - abort(404, 'Marhun tidak dijumpai.'); - } - if ($id === null) { - $id = $pending['next_item']++; - } - $item = array_merge($pending['items'][$id] ?? [], $fields, ['recno' => (int) $id]); - if ($delete) { - unset($pending['items'][$id]); - } else { - $pending['items'][$id] = $item; - } - $pending['revision'] = ($pending['revision'] ?? 0) + 1; - $pending['prepared'] = false; // Reprice the contract after any collateral edit. - $this->put($branch, $reference, $pending); - return $item; - }); - } - - public function promote(ShorakaTransaction $order): void - { - if ($order->status !== 'Completed' || !$this->pending($order->kodcaw, $order->norujukan)) { - return; - } - $this->locked($order->kodcaw . ':' . $order->norujukan, function () use ($order) { - $pending = $this->pending($order->kodcaw, $order->norujukan); - $data = $pending['gadai']; - if (!$pending['prepared'] - || number_format((float) ($data['pinjaman'] ?? 0), 2, '.', '') !== $order->order_amount - || number_format((float) ($data['hargajualan'] ?? 0), 2, '.', '') !== $order->murabaha_amount) { - abort(409, 'Amaun gadaian tidak sepadan dengan Shoraka.'); - } - $branch = Cawangan::on('mysql7')->where('kodcaw', $order->kodcaw)->firstOrFail(); - $connection = (new Gadai())->setConnection($branch->mysql)->getConnection(); - $connection->transaction(function () use ($connection, $pending, $data, $order) { - $existing = $connection->table('gadai')->where('norujukan', $order->norujukan)->lockForUpdate()->first(); - if ($existing) { - if ($existing->kodcaw !== $order->kodcaw - || number_format((float) $existing->pinjaman, 2, '.', '') !== $order->order_amount - || number_format((float) $existing->hargajualan, 2, '.', '') !== $order->murabaha_amount) { - abort(409, 'Rujukan gadaian bertindih. Semakan diperlukan.'); - } - return; - } - $connection->table('gadai')->insert(array_merge($data, ['tagkomuditi' => 1])); - foreach ($pending['items'] as $item) { - unset($item['recno']); - $connection->table('marhun')->insert($item); - } - }); - \App\Audit::firstOrCreate([ - 'kodcaw' => $order->kodcaw, 'norujukan' => $order->norujukan, 'actions' => 'Daftar Gadai', - ], ['users' => $data['teller'] ?? '', 'new_data' => json_encode($data)]); - // Retain the cache briefly for idempotent reconciliation after a lost response. - }); - } -} diff --git a/app/ShorakaFinancing.php b/app/ShorakaFinancing.php new file mode 100644 index 0000000..bf919f7 --- /dev/null +++ b/app/ShorakaFinancing.php @@ -0,0 +1,16 @@ + 'array']; +} diff --git a/app/Support/v1/GadaiV1.php b/app/Support/v1/GadaiV1.php index fb094c9..aa0e74b 100644 --- a/app/Support/v1/GadaiV1.php +++ b/app/Support/v1/GadaiV1.php @@ -73,13 +73,10 @@ class GadaiV1 $old_data = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->first(); } - $temporary = app(\App\Services\ShorakaNewGadaiService::class); - $temporaryData = $temporary->pending($request->kodcaw, $norujukan); - $isTemporary = $temporaryData !== null; - $bil_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->count() : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->count()); + $bil_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->count(); - $total_nilai_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('n_marhun') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun')); - $total_berat = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('berat') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat')); + $total_nilai_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun'); + $total_berat = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat'); $kadar_raw = KadarUpah::where('min','<=',$total_nilai_marhun)->where('max','>=',$total_nilai_marhun)->first(); $tawarruqservices = new Calculation(); @@ -117,10 +114,6 @@ class GadaiV1 'tempohbayar' => 0.00, 'lelong_tawarruq' => 1 ); - if ($isTemporary) { - $temporary->prepare($request->kodcaw, $norujukan, $gadaiFields, $temporaryData['revision'] ?? 0); - return response()->json(true); - } $gadai = Gadai::on($cawangan['mysql'])->where('norujukan', $norujukan)->update($gadaiFields); $new_data = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->first(); diff --git a/app/Support/v1/Penebusan.php b/app/Support/v1/Penebusan.php index e0a864a..4500d8e 100644 --- a/app/Support/v1/Penebusan.php +++ b/app/Support/v1/Penebusan.php @@ -946,8 +946,7 @@ class Penebusan $gadaiTransaction = Gadai::on($cawangan['mysql'])->where('t_gadai','=',$current->toDateString())->orderBy('sirig', 'desc')->first(); $gadaiTransaction = optional($gadaiTransaction)->toArray(); - $new_sirig = max((int) ($gadaiTransaction['sirig'] ?? 0), - app(\App\Services\ShorakaNewGadaiService::class)->latestSerial($kodcaw, $current->toDateString())) + 1; + $new_sirig = (int) ($gadaiTransaction['sirig'] ?? 0) + 1; $curent_year = substr($current->year,2,2); $curent_month = sprintf("%02d", $current->month); $current_day = sprintf("%02d", $current->day); diff --git a/app/Support/v2/GadaiV2.php b/app/Support/v2/GadaiV2.php index fa40cf5..7c17ca5 100644 --- a/app/Support/v2/GadaiV2.php +++ b/app/Support/v2/GadaiV2.php @@ -107,13 +107,10 @@ class GadaiV2 $old_data = Gadai::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->first(); } - $temporary = app(\App\Services\ShorakaNewGadaiService::class); - $temporaryData = $temporary->pending($request->kodcaw, $norujukan); - $isTemporary = $temporaryData !== null; - $bil_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('bil') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('bil')); + $bil_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('bil'); - $total_nilai_marhun = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('n_marhun') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun')); - $total_berat = ($isTemporary ? $temporary->items($request->kodcaw, $norujukan)->sum('berat') : Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat')); + $total_nilai_marhun = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('n_marhun'); + $total_berat = Marhun::on($cawangan['mysql'])->where('norujukan','=',$norujukan)->sum('berat'); $kadar_raw = $this->getKadarUpahAnggota($request->nokp, $total_nilai_marhun, $cawangan['group_id']); @@ -193,10 +190,6 @@ if (is_null($kadar_raw)) { } } - if ($isTemporary) { - $temporary->prepare($request->kodcaw, $norujukan, $gadaiFields, $temporaryData['revision'] ?? 0); - return 'true'; - } $gadai = Gadai::on($cawangan['mysql']) ->where('norujukan','=',$norujukan) ->update($gadaiFields); diff --git a/app/Support/v2/Penebusan.php b/app/Support/v2/Penebusan.php index c3a47cc..887fa4a 100644 --- a/app/Support/v2/Penebusan.php +++ b/app/Support/v2/Penebusan.php @@ -1224,8 +1224,7 @@ class Penebusan $gadaiTransaction = Gadai::on($cawangan['mysql'])->where('t_gadai','=',$current->toDateString())->orderBy('sirig', 'desc')->first(); $gadaiTransaction = optional($gadaiTransaction)->toArray(); - $new_sirig = max((int) ($gadaiTransaction['sirig'] ?? 0), - app(\App\Services\ShorakaNewGadaiService::class)->latestSerial($kodcaw, $current->toDateString())) + 1; + $new_sirig = (int) ($gadaiTransaction['sirig'] ?? 0) + 1; $curent_year = substr($current->year,2,2); $curent_month = sprintf("%02d", $current->month); $current_day = sprintf("%02d", $current->day); diff --git a/routes/web.php b/routes/web.php index e34f9ce..3312168 100644 --- a/routes/web.php +++ b/routes/web.php @@ -120,6 +120,10 @@ $router->group(['prefix'=>'api'], function () use ($router){ $router->get('gadai/online/search/detail',['uses'=>'GadaiController@searchGadaiOnline']); $router->get('shoraka/readiness', ['uses' => 'ShorakaGadaiController@readiness']); + $router->get('shoraka/financings', ['uses' => 'ShorakaFinancingController@index']); + $router->get('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@show']); + $router->post('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@store']); + $router->put('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@update']); $router->get('shoraka/gadai/{kodcaw}/{norujukan}', ['uses' => 'ShorakaGadaiController@showGadai']); $router->post('shoraka/gadai/{kodcaw}/{norujukan}/submit', ['uses' => 'ShorakaGadaiController@submitGadai']); $router->post('shoraka/gadai/{kodcaw}/{norujukan}/refresh', ['uses' => 'ShorakaGadaiController@refreshGadai']); -- 2.52.0 From 8dd8def3588ab8cf55951a90f4acc68d35b05b7d Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 26 Oct 2026 15:04:43 +0800 Subject: [PATCH 8/8] DONE: drop table shoraka financings, and improve UI checking on shoraka order status --- app/Http/Controllers/ShorakaController.php | 58 ++++++ .../ShorakaFinancingController.php | 168 ------------------ .../Controllers/ShorakaGadaiController.php | 15 +- app/ShorakaFinancing.php | 16 -- app/ShorakaTransaction.php | 1 + ...create_master_shoraka_financings_table.php | 28 --- ...000_add_teller_to_shoraka_transactions.php | 23 +++ docs/shoraka-integration.md | 147 --------------- routes/web.php | 5 +- tests/ShorakaIntegrationTest.php | 2 + 10 files changed, 95 insertions(+), 368 deletions(-) delete mode 100644 app/Http/Controllers/ShorakaFinancingController.php delete mode 100644 app/ShorakaFinancing.php delete mode 100644 database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php create mode 100644 database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php delete mode 100644 docs/shoraka-integration.md diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php index 4f5e6e0..ef58838 100644 --- a/app/Http/Controllers/ShorakaController.php +++ b/app/Http/Controllers/ShorakaController.php @@ -12,6 +12,61 @@ use Throwable; class ShorakaController extends Controller { + public function index(Request $request) + { + $this->validate($request, [ + 'kodcaw' => 'nullable|string|max:30', + 'norujukan' => 'nullable|string|max:100', + 'status' => 'nullable|string|max:30', + 'date_from' => 'nullable|date_format:Y-m-d', + 'date_to' => 'nullable|date_format:Y-m-d', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + 'branch_scope' => 'nullable|string|max:30', + ]); + + $query = ShorakaTransaction::query(); + foreach (['kodcaw', 'norujukan', 'status'] as $field) { + if ($request->filled($field)) { + $query->where($field, $request->input($field)); + } + } + if ($request->filled('date_from')) { + $query->where('created_at', '>=', $request->input('date_from') . ' 00:00:00'); + } + if ($request->filled('date_to')) { + $query->where('created_at', '<', Carbon::parse($request->input('date_to'))->addDay()->format('Y-m-d') . ' 00:00:00'); + } + $records = $query->orderByDesc('created_at')->orderByDesc('id') + ->paginate(min(100, max(1, (int) $request->input('per_page', 25)))); + $payload = [ + 'data' => $records->getCollection()->map(function (ShorakaTransaction $record) { + return [ + 'id' => $record->id, + 'kodcaw' => $record->kodcaw, + 'norujukan' => $record->norujukan, + 'teller' => $record->teller, + 'status' => $record->status, + 'order_id' => $record->order_id, + 'created_at' => optional($record->created_at)->toDateTimeString(), + 'updated_at' => optional($record->updated_at)->toDateTimeString(), + ]; + })->values(), + 'total' => $records->total(), + 'current_page' => $records->currentPage(), + 'per_page' => $records->perPage(), + 'last_page' => $records->lastPage(), + ]; + if ($request->input('with_branches')) { + $branches = ShorakaTransaction::query()->whereNotNull('kodcaw'); + if ($request->filled('branch_scope')) { + $branches->where('kodcaw', $request->input('branch_scope')); + } + $payload['branches'] = $branches->distinct()->orderBy('kodcaw')->pluck('kodcaw')->values(); + } + return response()->json($payload); + } + public function submit(Request $request, ShorakaService $shoraka) { $validated = $this->validate($request, [ @@ -20,6 +75,7 @@ class ShorakaController extends Controller 'order_amount' => 'required|numeric|min:0.01|max:9999999999999.99', 'murabaha_amount' => 'required|numeric|min:0.01|max:9999999999999.99', 'tenor_other' => 'required|integer|min:1', + 'teller' => 'nullable|string|max:255', ]); if (!$shoraka->configured()) { return $this->unconfigured(); @@ -51,6 +107,7 @@ class ShorakaController extends Controller $attributes = [ 'norujukan' => $validated['norujukan'], 'kodcaw' => $request->attributes->get('shoraka_kodcaw'), + 'teller' => $validated['teller'] ?? null, 'status' => 'PENDING', 'product_type' => $payload['product_type'], 'commodity_type' => $payload['commodity_type'], @@ -185,6 +242,7 @@ class ShorakaController extends Controller return [ 'norujukan' => $transaction->norujukan, 'kodcaw' => $transaction->kodcaw, + 'teller' => $transaction->teller, 'order_id' => $transaction->order_id, 'status' => $transaction->status, 'certificate_url' => $transaction->certificate_url, diff --git a/app/Http/Controllers/ShorakaFinancingController.php b/app/Http/Controllers/ShorakaFinancingController.php deleted file mode 100644 index 32b63c7..0000000 --- a/app/Http/Controllers/ShorakaFinancingController.php +++ /dev/null @@ -1,168 +0,0 @@ -validate($request, [ - 'kodcaw' => 'nullable|string|max:30', - 'norujukan' => 'nullable|string|max:100', - 'state' => 'nullable|in:WAITING,PROCESSING,REVIEW,DONE', - 'date_from' => 'nullable|date_format:Y-m-d', - 'date_to' => 'nullable|date_format:Y-m-d', - 'per_page' => 'nullable|integer|min:1|max:100', - 'page' => 'nullable|integer|min:1', - 'branch_scope' => 'nullable|string|max:30', - ]); - - try { - $query = ShorakaFinancing::query(); - foreach (['kodcaw', 'norujukan', 'state'] as $field) { - if ($request->filled($field)) { - $query->where($field, $request->input($field)); - } - } - if ($request->filled('date_from')) { - $query->where('created_at', '>=', $request->input('date_from') . ' 00:00:00'); - } - if ($request->filled('date_to')) { - $query->where('created_at', '<', Carbon::parse($request->input('date_to'))->addDay()->format('Y-m-d') . ' 00:00:00'); - } - $records = $query->orderByDesc('created_at')->orderByDesc('id') - ->paginate(min(100, max(1, (int) $request->input('per_page', 25)))); - $payload = [ - 'data' => $records->getCollection()->map(function (ShorakaFinancing $record) { - return $this->present($record, false); - })->values(), - 'total' => $records->total(), - 'current_page' => $records->currentPage(), - 'per_page' => $records->perPage(), - 'last_page' => $records->lastPage(), - ]; - if ($request->input('with_branches')) { - $branches = ShorakaFinancing::query(); - if ($request->filled('branch_scope')) { - $branches->where('kodcaw', $request->input('branch_scope')); - } - $payload['branches'] = $branches->distinct()->orderBy('kodcaw')->pluck('kodcaw')->values(); - } - return response()->json($payload); - } catch (QueryException $exception) { - return $this->unavailable(); - } - } - - public function show(string $kodcaw, string $norujukan) - { - try { - $record = $this->find($kodcaw, $norujukan); - } catch (QueryException $exception) { - return $this->unavailable(); - } - if (!$record) { - return response()->json(['message' => 'Rekod pembiayaan tidak dijumpai.'], 404); - } - return response()->json(['data' => $this->present($record)]); - } - - public function store(string $kodcaw, string $norujukan, Request $request) - { - $this->validate($request, [ - 'teller' => 'required|string|max:255', - 'action' => 'required|string|max:30', - 'context' => 'present|array', - ]); - try { - $existing = $this->find($kodcaw, $norujukan); - if ($existing) { - return response()->json(['created' => false, 'data' => $this->present($existing)]); - } - $record = ShorakaFinancing::create([ - 'kodcaw' => $kodcaw, - 'norujukan' => $norujukan, - 'teller' => $request->input('teller'), - 'action' => $request->input('action'), - 'state' => 'WAITING', - 'context' => $request->input('context'), - ]); - return response()->json(['created' => true, 'data' => $this->present($record)], 201); - } catch (QueryException $exception) { - try { - $existing = $this->find($kodcaw, $norujukan); - } catch (QueryException $again) { - return $this->unavailable(); - } - if ($existing) { - return response()->json(['created' => false, 'data' => $this->present($existing)]); - } - return $this->unavailable(); - } - } - - public function update(string $kodcaw, string $norujukan, Request $request) - { - $this->validate($request, [ - 'state' => 'required|in:WAITING,PROCESSING,REVIEW,DONE', - 'expected_state' => 'nullable|in:WAITING,PROCESSING,REVIEW,DONE', - 'context' => 'nullable|array', - ]); - try { - $record = $this->find($kodcaw, $norujukan); - if (!$record) { - return response()->json(['message' => 'Rekod pembiayaan tidak dijumpai.'], 404); - } - if ($request->filled('expected_state')) { - $claimed = ShorakaFinancing::where('id', $record->id) - ->where('state', $request->input('expected_state')) - ->update(['state' => $request->input('state'), 'updated_at' => Carbon::now()]); - return response()->json([ - 'claimed' => $claimed === 1, - 'data' => $this->present($record->fresh()), - ]); - } - $fields = ['state' => $request->input('state')]; - if ($request->exists('context')) { - $fields['context'] = $request->input('context') ?? []; - } - $record->update($fields); - return response()->json(['claimed' => true, 'data' => $this->present($record->fresh())]); - } catch (QueryException $exception) { - return $this->unavailable(); - } - } - - private function find(string $kodcaw, string $norujukan): ?ShorakaFinancing - { - return ShorakaFinancing::where('kodcaw', $kodcaw)->where('norujukan', $norujukan)->first(); - } - - private function present(ShorakaFinancing $record, bool $withContext = true): array - { - $data = [ - 'id' => $record->id, - 'kodcaw' => $record->kodcaw, - 'norujukan' => $record->norujukan, - 'teller' => $record->teller, - 'action' => $record->action, - 'state' => $record->state, - 'created_at' => optional($record->created_at)->toDateTimeString(), - 'updated_at' => optional($record->updated_at)->toDateTimeString(), - ]; - if ($withContext) { - $data['context'] = $record->context ?? []; - } - return $data; - } - - private function unavailable() - { - return response()->json(['message' => 'Rekod pembiayaan Shoraka tidak tersedia.'], 503); - } -} diff --git a/app/Http/Controllers/ShorakaGadaiController.php b/app/Http/Controllers/ShorakaGadaiController.php index ab9ac86..4fb772a 100644 --- a/app/Http/Controllers/ShorakaGadaiController.php +++ b/app/Http/Controllers/ShorakaGadaiController.php @@ -12,7 +12,7 @@ class ShorakaGadaiController extends ShorakaController public function readiness(ShorakaService $shoraka) { try { - \App\ShorakaFinancing::query()->limit(1)->exists(); + \App\ShorakaTransaction::query()->limit(1)->exists(); $storeReady = true; } catch (\Throwable $exception) { $storeReady = false; @@ -39,7 +39,7 @@ class ShorakaGadaiController extends ShorakaController ]); } - public function submitGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) + public function submitGadai(string $kodcaw, string $norujukan, Request $request, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) { $gadai = $gadaiService->gadai($kodcaw, $norujukan); $existing = $gadaiService->transaction($kodcaw, $norujukan); @@ -51,9 +51,14 @@ class ShorakaGadaiController extends ShorakaController || $gadai->cetak || $gadai->sttebus) { abort(409, 'Gadaian lama atau telah selesai tidak boleh dihantar sebagai order baharu.'); } - $request = new Request($gadaiService->payload($gadai)); - $request->attributes->set('shoraka_kodcaw', $kodcaw); - return $this->submit($request, $shoraka); + $payload = $gadaiService->payload($gadai); + $teller = $request->input('teller'); + if (is_string($teller) && $teller !== '') { + $payload['teller'] = $teller; + } + $submission = new Request($payload); + $submission->attributes->set('shoraka_kodcaw', $kodcaw); + return $this->submit($submission, $shoraka); } public function refreshGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) diff --git a/app/ShorakaFinancing.php b/app/ShorakaFinancing.php deleted file mode 100644 index bf919f7..0000000 --- a/app/ShorakaFinancing.php +++ /dev/null @@ -1,16 +0,0 @@ - 'array']; -} diff --git a/app/ShorakaTransaction.php b/app/ShorakaTransaction.php index 160a1ca..bb0938a 100644 --- a/app/ShorakaTransaction.php +++ b/app/ShorakaTransaction.php @@ -9,6 +9,7 @@ class ShorakaTransaction extends Model protected $fillable = [ 'norujukan', 'kodcaw', + 'teller', 'order_id', 'status', 'product_type', diff --git a/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php b/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php deleted file mode 100644 index 06419e7..0000000 --- a/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -create('shoraka_financings', function (Blueprint $table) { - $table->bigIncrements('id'); - $table->string('kodcaw', 20); - $table->string('norujukan'); - $table->string('teller'); - $table->string('action', 30); - $table->string('state', 30)->default('WAITING'); - $table->json('context'); - $table->timestamps(); - $table->unique(['kodcaw', 'norujukan']); - }); - } - - public function down() - { - Schema::connection('mysql7')->dropIfExists('shoraka_financings'); - } -} diff --git a/database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php b/database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php new file mode 100644 index 0000000..7e6f512 --- /dev/null +++ b/database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php @@ -0,0 +1,23 @@ +string('teller')->nullable(); + }); + } + + public function down() + { + Schema::table('shoraka_transactions', function (Blueprint $table) { + $table->dropColumn('teller'); + }); + } +} diff --git a/docs/shoraka-integration.md b/docs/shoraka-integration.md deleted file mode 100644 index f3cb510..0000000 --- a/docs/shoraka-integration.md +++ /dev/null @@ -1,147 +0,0 @@ -# Shoraka STP integration and kaunter workflow - -Based on Shoraka Al-Amin STP API v1.5.0 (9 January 2026). - -## Agreed behavior - -New financing uses Shoraka instead of deducting internal commodity stock. -Cash/online financing completion and SAG printing wait for a Completed trade. -Existing historical commodity records retain their legacy reporting/printing path. - -The counter saves financing details, records a resumable local workflow, and -submits the trade. It redirects the teller to **Pembiayaan Shoraka**. -The teller uses **Semak Status**, then **Selesaikan Pembiayaan** when eligible. -This second step performs the cash update or registers the online payment for -the existing PC approval flow. It does not itself transfer funds through a bank. - -Ordinary financing, the two older refinancing paths, additional financing and -auto-tawarruq use this workflow. API refinancing returns norujukan_baru to ensure -the commodity trade belongs to the newly created financing, not the redeemed one. -Existing refinancing registration/settlement database writes still occur before -the commodity request; the counter cash completion and SAG are held afterwards. - -## Configuration and migrations - -API environment settings: - -- SHORAKA_BASE_URL: default https://uat2.shorakagroup.com -- SHORAKA_API_ID and SHORAKA_SECRET_KEY: supplied by Shoraka -- SHORAKA_TIMEOUT: seconds, default 60 - -The counter calls its existing API_URL; Shoraka credentials are kept in the API. -Register the public HTTPS /api/shoraka/callback URL and arrange server IP -whitelisting with Shoraka. Confirm the configured environment before UAT/live use. - -Deploy both repositories together and apply the following migrations using the -normal deployment process, before enabling counter traffic. They are not run -against application databases by the automated tests. - -In api-kopkb (configured default database; existing migration may already be applied): - -~~~sh -php artisan migrate --path=database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php -php artisan migrate --path=database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php -~~~ - -In kaunter-kopkb (counter application database): - -~~~sh -php artisan migrate --path=database/migrations/2026_10_05_120000_create_shoraka_financings_table.php -~~~ - -Clear/rebuild any deployment route, config and view caches through the usual -deployment process. Do not mix a newly deployed API with an old counter build. - -## Data mapping - -| Shoraka field | Source | -| --- | --- | -| ownership | Central customer.nama matched using gadai.nokp | -| ownership_doc_no | gadai.norujukan | -| order_amount | Saved gadai.pinjaman | -| murabaha_amount | Saved gadai.hargajualan | -| tenor / tenor_other / unit | OTHERS / 12 / MONTH | -| product / commodity / currency / order | FINANCING / 000-COPPER / MYR / Buy & Sell | - -Both current financing engines calculate the contract using upah12 and a -12-month contract end date. The elapsed-charge fields tempoh and tempohbayar -are not contractual tenor fields. - -The backend derives amounts and customer identity; it ignores client-supplied -amounts on the branch submission endpoint. Each Shoraka order stores kodcaw. -A completed response/callback synchronizes gadai.tagkomuditi to 1 only when saved -financing amounts still match. Contract edits after submission are rejected. -Payment completion also verifies the staged payout amount. - -## API endpoints - -| Method | Path | Purpose | -| --- | --- | --- | -| GET | /api/shoraka/readiness | Configuration readiness (not a live connectivity probe) | -| GET | /api/shoraka/gadai/{kodcaw}/{norujukan} | Local order, financing amounts and completion eligibility | -| POST | /api/shoraka/gadai/{kodcaw}/{norujukan}/submit | Submit from saved financing data | -| POST | /api/shoraka/gadai/{kodcaw}/{norujukan}/refresh | Query Shoraka and synchronize branch status | -| GET | /api/shoraka/gadai/{kodcaw}/{norujukan}/certificate | Download the fixed-endpoint PDF after Completed | -| POST | /api/shoraka/callback | Verify signature, refresh and acknowledge | -| POST | /api/shoraka/orders | Existing standalone submission contract | -| POST | /api/shoraka/orders/{norujukan}/refresh | Existing standalone refresh | -| GET | /api/shoraka/orders/{norujukan}/certificate | Existing standalone PDF download | - -The old PUT /api/komuditi/transaksi endpoint now delegates to branch Shoraka -submission. New loans do not consume KomuditiPurchase stock. Historical stock -administration remains available; old stock update/cancel endpoints reject -references already associated with Shoraka. - -Counter /shoraka pages require session authentication, an authorized role and -the user's branch. Only the original teller can submit/finish a staged financing. -POST forms use CSRF protection. API endpoints retain the repository's existing -network/access-control arrangement; restrict API access accordingly. - -## Completion and recovery - -shoraka_financings is the counter's durable completion record: - -- WAITING: awaiting trade completion or teller completion. -- PROCESSING: one request has atomically claimed payment completion. -- DONE: completion returned successfully; saved request context is cleared. -- REVIEW: completion raised an error; do not retry payment automatically. - -Only one WAITING request can claim completion. Repeated requests for DONE do -not repeat cash/online processing. A worker crash while PROCESSING or an -ambiguous response during payment requires operational reconciliation. - -Shoraka PENDING/UNKNOWN/legacy FAILED entries block repeated submissions. -A timeout does not prove a remote trade failed. If an orderId is known, refresh. -Otherwise reconcile the reference with Shoraka and link/correct the record -through an authorized operational process before resubmission. - -A failure between remote financing registration and local workflow creation -also requires reconciliation using the newly created reference. Do not repeat -the original refinancing operation, which may already have settled the old loan. -The guide provides neither a reference-based lookup nor an idempotency key. - -Historical print paths remain for records with no Shoraka order/workflow. -For Shoraka financing, SAG metadata uses certificateDetails1, commodityType, -unit and volume from its own trade. The separate PDF is fetched from the signed -certificate endpoint. Missing certificate metadata blocks SAG printing. - -## Protocol choices and UAT checks - -- Appendix specifies OTHERS although prose says OTHER; use OTHERS. -- Section 5 specifies POST /api/certificate although history says GET; use POST. -- Order lookup/PDF signature: SECRET_KEY;API_ID;order_id. -- Callback camelCase strings are hashed in documented order without decimal normalization. -- ownershipDocNo and certificate fields are unsigned. Match a known orderId and - re-query current status rather than trusting those callback fields. -- Respond with the exact plain text OK only after status persistence/synchronization. -- Unknown callback orderId returns 404. An early callback needs redelivery or - subsequent refresh; the guide does not specify a retry policy. -- Confirm protocol inconsistencies, callback delivery and full certificate metadata in UAT. - -## Tests - -API: tests/ShorakaGadaiTest.php (includes the existing protocol regression tests). -Counter: tests/Feature/ShorakaFinancingTest.php. - -Use PHPUnit with PDO SQLite enabled. Tests use in-memory databases and fake -HTTP responses. They do not run live trades, payouts or branch migrations. diff --git a/routes/web.php b/routes/web.php index 3312168..db59f2c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -120,10 +120,7 @@ $router->group(['prefix'=>'api'], function () use ($router){ $router->get('gadai/online/search/detail',['uses'=>'GadaiController@searchGadaiOnline']); $router->get('shoraka/readiness', ['uses' => 'ShorakaGadaiController@readiness']); - $router->get('shoraka/financings', ['uses' => 'ShorakaFinancingController@index']); - $router->get('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@show']); - $router->post('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@store']); - $router->put('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@update']); + $router->get('shoraka/transactions', ['uses' => 'ShorakaController@index']); $router->get('shoraka/gadai/{kodcaw}/{norujukan}', ['uses' => 'ShorakaGadaiController@showGadai']); $router->post('shoraka/gadai/{kodcaw}/{norujukan}/submit', ['uses' => 'ShorakaGadaiController@submitGadai']); $router->post('shoraka/gadai/{kodcaw}/{norujukan}/refresh', ['uses' => 'ShorakaGadaiController@refreshGadai']); diff --git a/tests/ShorakaIntegrationTest.php b/tests/ShorakaIntegrationTest.php index d5d1e34..30d36cb 100644 --- a/tests/ShorakaIntegrationTest.php +++ b/tests/ShorakaIntegrationTest.php @@ -28,6 +28,8 @@ class ShorakaIntegrationTest extends TestCase (new CreateShorakaTransactionsTable())->up(); require_once __DIR__ . '/../database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php'; (new AddKodcawToShorakaTransactions())->up(); + require_once __DIR__ . '/../database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php'; + (new AddTellerToShorakaTransactions())->up(); Http::swap(new Factory()); // Fail any HTTP request that a test did not explicitly stub. Log::shouldReceive('error')->zeroOrMoreTimes(); -- 2.52.0