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(); + } +}