diff --git a/app/Http/Controllers/GadaiController.php b/app/Http/Controllers/GadaiController.php index 0c8006f..1894cdd 100644 --- a/app/Http/Controllers/GadaiController.php +++ b/app/Http/Controllers/GadaiController.php @@ -158,7 +158,6 @@ class GadaiController extends Controller } public function create($kodcaw,Request $request){ - $master = new MasterServices($kodcaw); $response = $master->createGadai($request); @@ -228,11 +227,14 @@ class GadaiController extends Controller } - public function daftarGadaiMarhun($norujukan,Request $request){ - // dd("ggwp"); - $master = new MasterServices($request->kodcaw); - $response = $master->createMarhunGadai($request->norujukan,$request); - + 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.'); + } + $request->merge(['kodcaw' => $kodcaw, 'norujukan' => $norujukan]); + $master = new MasterServices($kodcaw); + $response = $master->createMarhunGadai($norujukan, $request); return response()->json($response, 200); } @@ -369,6 +371,9 @@ class GadaiController extends Controller } public function batalGadai($kodcaw, $norujukan,Request $request){ + if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) { + abort(409, 'Transaksi Shoraka telah dihantar. Semakan operasi diperlukan sebelum pembatalan.'); + } $current = Carbon::now(); $current = new Carbon(); @@ -406,6 +411,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 +428,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 +456,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/MarhunController.php b/app/Http/Controllers/MarhunController.php index cf5d893..38b94db 100644 --- a/app/Http/Controllers/MarhunController.php +++ b/app/Http/Controllers/MarhunController.php @@ -59,6 +59,11 @@ class MarhunController extends Controller public function create($kodcaw,Request $request) { + $order = \App\ShorakaTransaction::where('norujukan', $request->norujukan)->first(); + if ($order) { + abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); + } + $current = Carbon::now(); $current = new Carbon(); @@ -127,6 +132,10 @@ class MarhunController extends Controller public function deletemarhun($kodcaw,$norujukan,Request $request) { + $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); + if ($order) { + abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); + } $current = Carbon::now(); $current = new Carbon(); @@ -152,6 +161,11 @@ class MarhunController extends Controller public function editmarhun($kodcaw,$norujukan,Request $request) { + $order = \App\ShorakaTransaction::where('norujukan', $norujukan)->first(); + if ($order) { + abort(409, 'Kontrak Shoraka telah dihantar dan tidak boleh diubah.'); + } + $current = Carbon::now(); $current = new Carbon(); diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php new file mode 100644 index 0000000..ef58838 --- /dev/null +++ b/app/Http/Controllers/ShorakaController.php @@ -0,0 +1,291 @@ +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, [ + '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', + 'teller' => 'nullable|string|max:255', + ]); + if (!$shoraka->configured()) { + return $this->unconfigured(); + } + + $existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first(); + if ($existing) { + return $this->duplicate($existing); + } + + $payload = [ + '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', + ]; + + try { + // The unique norujukan index is the atomic claim for concurrent submissions. + $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'], + '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, + ]; + $transaction = ShorakaTransaction::create($attributes); + } catch (QueryException $exception) { + $existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first(); + if ($existing) { + return $this->duplicate($existing); + } + throw $exception; + } + + try { + $response = $shoraka->submitOrder($payload); + $this->saveOrder($transaction, $response); + + return response()->json([ + 'success' => true, + 'message' => 'Order berjaya dihantar ke Shoraka.', + 'data' => $this->orderData($transaction), + ]); + } catch (Throwable $exception) { + // 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' => $transaction->order_id ? $transaction->status : 'UNKNOWN', + 'error_message' => 'Submission outcome requires reconciliation with Shoraka.', + ]); + + return $this->upstreamError('submit', $transaction, $exception); + } + } + + 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, + 'teller' => $transaction->teller, + '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..4fb772a --- /dev/null +++ b/app/Http/Controllers/ShorakaGadaiController.php @@ -0,0 +1,76 @@ +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) + { + $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, + '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, + ], + ]); + } + + public function submitGadai(string $kodcaw, string $norujukan, Request $request, 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.'); + } + $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) + { + $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..61d1b14 --- /dev/null +++ b/app/Services/ShorakaGadaiService.php @@ -0,0 +1,77 @@ +where('kodcaw', $kodcaw)->firstOrFail(); + return Gadai::on($branch->mysql)->where('kodcaw', $kodcaw)->where('norujukan', $norujukan)->first() + ?? abort(404, 'Maklumat gadaian tidak tersedia. Semakan diperlukan.'); + } + + 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); + 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([ + '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 (!$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/ShorakaService.php b/app/Services/ShorakaService.php new file mode 100644 index 0000000..dec33cd --- /dev/null +++ b/app/Services/ShorakaService.php @@ -0,0 +1,220 @@ +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); + } + + 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', + '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, '.', ''); + } + + // 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)); + + return $this->orderResponse($this->request()->post( + $this->baseUrl . '/api/submitorder', $payload + )); + } + + 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); + } + + 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) + ); + $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->simulationRequested() || strpos((string) ($payload['orderId'] ?? ''), 'SIM-') === 0) { + return false; + } + + 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; + } +} diff --git a/app/ShorakaTransaction.php b/app/ShorakaTransaction.php new file mode 100644 index 0000000..bb0938a --- /dev/null +++ b/app/ShorakaTransaction.php @@ -0,0 +1,41 @@ + '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/app/Support/v1/GadaiV1.php b/app/Support/v1/GadaiV1.php index a35e5d9..aa0e74b 100644 --- a/app/Support/v1/GadaiV1.php +++ b/app/Support/v1/GadaiV1.php @@ -88,9 +88,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 +113,8 @@ class GadaiV1 'upahdibayar' => 0.00, 'tempohbayar' => 0.00, 'lelong_tawarruq' => 1 - )); + ); + $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 00bde97..4500d8e 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(); @@ -949,7 +946,7 @@ 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 = (int) ($gadaiTransaction['sirig'] ?? 0) + 1; $curent_year = substr($current->year,2,2); $curent_month = sprintf("%02d", $current->month); $current_day = sprintf("%02d", $current->day); @@ -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..887fa4a 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(); @@ -1228,7 +1224,7 @@ 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 = (int) ($gadaiTransaction['sirig'] ?? 0) + 1; $curent_year = substr($current->year,2,2); $curent_month = sprintf("%02d", $current->month); $current_day = sprintf("%02d", $current->day); @@ -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/bootstrap/app.php b/bootstrap/app.php index 26b6559..428e465 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('logviewer'); $app->configure('tinker'); diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..d0db227 --- /dev/null +++ b/config/services.php @@ -0,0 +1,23 @@ + [ + // 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' + ), + + '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/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/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/docker/php/Dockerfile b/docker/php/Dockerfile index 45aac51..8479d61 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,9 +35,12 @@ 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 \ - && usermod -u 1000 www-data && groupmod -g 1000 www-data +RUN update-ca-certificates \ + && chmod +x /usr/local/bin/app-entrypoint.sh \ + && 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"] 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 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 982afc3..e008951 100644 --- a/routes/web.php +++ b/routes/web.php @@ -119,6 +119,19 @@ $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/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']); + $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']); $router->get('check/serah/lelong',['uses'=>'LelongController@checkSerahLelong']); 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..30d36cb --- /dev/null +++ b/tests/ShorakaIntegrationTest.php @@ -0,0 +1,260 @@ +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(); + 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(); + } + + 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(); + } +}