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', ]); 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. $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 { $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, '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); } }