From 0451ad3dbe372c18ba9ee8b49bab53996f918bdc Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 26 Oct 2026 15:35:48 +0800 Subject: [PATCH] Feature/bsas integration (#24) Reviewed-on: https://git.koppkb.com/KoPKB/api_arrahn/pulls/24 Co-authored-by: ISMAIL MASSERAN Co-committed-by: ISMAIL MASSERAN --- .env.docker | 13 +- .env.example | 13 +- app/BsasOrder.php | 44 +++ app/Http/Controllers/KomuditiController.php | 194 +++++++++ app/Services/Bsas/BsasClient.php | 193 +++++++++ app/Services/Bsas/BsasOrderService.php | 374 ++++++++++++++++++ bootstrap/app.php | 1 + config/bsas.php | 14 + ..._10_13_160000_create_bsas_orders_table.php | 49 +++ routes/web.php | 7 + 10 files changed, 900 insertions(+), 2 deletions(-) create mode 100644 app/BsasOrder.php create mode 100644 app/Services/Bsas/BsasClient.php create mode 100644 app/Services/Bsas/BsasOrderService.php create mode 100644 config/bsas.php create mode 100644 database/migrations/2026_10_13_160000_create_bsas_orders_table.php diff --git a/.env.docker b/.env.docker index 7cee720..d68a0b6 100644 --- a/.env.docker +++ b/.env.docker @@ -68,4 +68,15 @@ KAUNTER_URL=http://host.docker.internal:8081 INTERNAL_NOTIFICATION_SECRET=erahn-local-notification-secret CACHE_DRIVER=file -QUEUE_CONNECTION=sync \ No newline at end of file +QUEUE_CONNECTION=sync + +BSAS_BASE_URL=https://bsasdevapi.bursamalaysia.com/api +BSAS_TOKEN_URL=https://bsasdevapi.bursamalaysia.com/oauth/token +BSAS_CLIENT_ID= +BSAS_CLIENT_SECRET= +BSAS_MEMBER_SHORT_NAME=KOOPPKB +BSAS_PRODUCT_CODE=CPO-MSIA-09 +BSAS_TENOR=00088 +BSAS_MAX_WAIT_TIME=10 +BSAS_HTTP_TIMEOUT=30 +BSAS_ORDER_RESULT_RETRIES=2 \ No newline at end of file diff --git a/.env.example b/.env.example index 5ab7bba..6d5dc42 100644 --- a/.env.example +++ b/.env.example @@ -107,4 +107,15 @@ WHATSAPP_TEMPLATE=hello_world WHATSAPP_TEMPLATE_LANG=en_US WHATSAPP_VERIFY_TOKEN= # webhook verify token WHATSAPP_APP_SECRET= # X-Hub-Signature-256 checks; need to be enable first -ADMIN_PHONE_NUMBER=60193450165 \ No newline at end of file +ADMIN_PHONE_NUMBER=60193450165 + +BSAS_BASE_URL=https://bsasdevapi.bursamalaysia.com/api +BSAS_TOKEN_URL=https://bsasdevapi.bursamalaysia.com/oauth/token +BSAS_CLIENT_ID= +BSAS_CLIENT_SECRET= +BSAS_MEMBER_SHORT_NAME=KOOPPKB +BSAS_PRODUCT_CODE=CPO-MSIA-09 +BSAS_TENOR=00088 +BSAS_MAX_WAIT_TIME=10 +BSAS_HTTP_TIMEOUT=30 +BSAS_ORDER_RESULT_RETRIES=2 \ No newline at end of file diff --git a/app/BsasOrder.php b/app/BsasOrder.php new file mode 100644 index 0000000..9dd03ed --- /dev/null +++ b/app/BsasOrder.php @@ -0,0 +1,44 @@ + 'decimal:2', + 'otc_murabaha_value' => 'decimal:2', + 'request_payload' => 'array', + 'order_response' => 'array', + 'result_response' => 'array', + 'bid_ecert_response' => 'array', + 'otc_ecert_response' => 'array', + 'stb_ecert_response' => 'array', + ]; +} diff --git a/app/Http/Controllers/KomuditiController.php b/app/Http/Controllers/KomuditiController.php index ff512b6..efecb9f 100644 --- a/app/Http/Controllers/KomuditiController.php +++ b/app/Http/Controllers/KomuditiController.php @@ -3,15 +3,18 @@ namespace App\Http\Controllers; use App\Audit; +use App\BsasOrder; use App\Komuditi; use App\KomuditiPurchase; use App\KomuditiTransaksi; use App\KomuditiProduk; use App\Cawangan; +use App\Services\Bsas\BsasOrderService; use DB; use Illuminate\Http\Request; use Carbon\Carbon; +use Illuminate\Support\Facades\Log; class KomuditiController extends Controller { @@ -338,4 +341,195 @@ class KomuditiController extends Controller return response()->json($rekodkomoditi); } + /** + * List BSAS commodity orders for operasi rekod view. + */ + public function listBsasOrders(Request $request) + { + $query = BsasOrder::on('mysql7') + ->select([ + 'id', + 'uuid', + 'norujukan', + 'kodcaw', + 'serial_number', + 'product_code', + 'bid_value', + 'otc_murabaha_value', + 'otc_counter_party', + 'status', + 'status_code', + 'status_message', + 'e_cert_no', + 'bid_ecert_response', + 'otc_ecert_response', + 'stb_ecert_response', + 'created_by', + 'created_at', + 'updated_at', + ]) + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc'); + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + if ($request->filled('kodcaw')) { + $query->where('kodcaw', $request->input('kodcaw')); + } + + if ($request->filled('limit_view')) { + $query->limit((int) $request->input('limit_view')); + } + + $rows = $query->get()->map(function (BsasOrder $order) { + $payload = $order->toArray(); + $payload['has_bid_ecert'] = !empty($order->bid_ecert_response); + $payload['has_otc_ecert'] = !empty($order->otc_ecert_response); + $payload['has_stb_ecert'] = !empty($order->stb_ecert_response); + unset($payload['bid_ecert_response'], $payload['otc_ecert_response'], $payload['stb_ecert_response']); + + return $payload; + }); + + return response()->json($rows); + } + + /** + * SAG-ready komoditi/product payload from confirmed BSAS order for a norujukan. + * Replaces daily KomuditiPurchase lookup for per-Terima BSAS purchases. + */ + public function getBsasOrderByNorujukan($norujukan) + { + $order = BsasOrder::on('mysql7') + ->where('norujukan', $norujukan) + ->where('status', 'confirmed') + ->orderBy('id', 'desc') + ->first(); + + if (!$order) { + return response()->json(['message' => 'BSAS order tidak dijumpai untuk norujukan ini.'], 404); + } + + $otc = is_array($order->otc_ecert_response) ? $order->otc_ecert_response : []; + $productCode = (string) ($order->product_code ?: config('bsas.product_code')); + $description = trim((string) ($otc['PNAME'] ?? '')); + if ($description === '') { + $produk = KomuditiProduk::on('mysql7')->where('kodproduk', $productCode)->first(); + $description = $produk ? (string) $produk->description : $productCode; + } + + $price = (float) ($otc['PRICE_MYR_EQUIVALENT'] ?? $otc['PRICE'] ?? 0); + $volume = (float) ($otc['PVOLUME'] ?? 0); + $pinjaman = (float) $order->bid_value; + if ($volume <= 0 && $price > 0 && $pinjaman > 0) { + $volume = round($pinjaman / $price, 4); + } + + return response()->json([ + 'source' => 'bsas', + 'uuid' => $order->uuid, + 'komoditi' => [ + 'no_sijil' => $order->e_cert_no ?: $order->uuid, + 'kodproduct' => $productCode, + 'nilai' => $price > 0 ? $price : null, + ], + 'product' => [ + 'product_code' => $productCode, + 'kodproduk' => $productCode, + 'description' => $description, + 'unit' => 'TAN', + ], + 'kuantiti' => $volume, + ]); + } + + /** + * Return stored BSAS eCert payload only (no live BSAS refresh). + */ + public function getBsasOrderEcert($uuid) + { + $order = BsasOrder::on('mysql7')->where('uuid', $uuid)->first(); + + if (!$order) { + return response()->json(['message' => 'BSAS order tidak dijumpai.'], 404); + } + + if (empty($order->bid_ecert_response) && empty($order->otc_ecert_response) && empty($order->stb_ecert_response)) { + return response()->json(['message' => 'eCert belum tersedia untuk order ini.'], 404); + } + + return response()->json([ + 'uuid' => $order->uuid, + 'norujukan' => $order->norujukan, + 'e_cert_no' => $order->e_cert_no, + 'serial_number' => $order->serial_number, + 'product_code' => $order->product_code, + 'bid_value' => $order->bid_value, + 'otc_murabaha_value' => $order->otc_murabaha_value, + 'otc_counter_party' => $order->otc_counter_party, + 'bid_ecert' => $order->bid_ecert_response, + 'otc_ecert' => $order->otc_ecert_response, + 'stb_ecert' => $order->stb_ecert_response, + ]); + } + + /** + * Place BSAS commodity order for a gadai Terima, long-poll result, fetch eCert. + */ + public function placeBsasOrder(Request $request) + { + try { + $service = new BsasOrderService(); + $order = $service->placeAndConfirm([ + 'norujukan' => $request->input('norujukan'), + 'kodcaw' => $request->input('kodcaw'), + 'pinjaman' => $request->input('pinjaman'), + 'hargajualan' => $request->input('hargajualan'), + 'nama_pelanggan' => $request->input('nama_pelanggan'), + 'teller' => $request->input('teller'), + ]); + + return response()->json([ + 'message' => 'BSAS order confirmed', + 'order' => $order, + ], 200); + } catch (\Throwable $e) { + Log::error('BSAS place order failed', [ + 'norujukan' => $request->input('norujukan'), + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => $e->getMessage() ?: 'Gagal pembelian komoditi BSAS.', + ], 422); + } + } + + /** + * Re-query BSAS orderResult / retry eCert by stored uuid. + */ + public function getBsasOrder($uuid) + { + try { + $service = new BsasOrderService(); + $order = $service->refreshByUuid($uuid); + + return response()->json([ + 'message' => 'OK', + 'order' => $order, + ], 200); + } catch (\Throwable $e) { + Log::error('BSAS refresh order failed', [ + 'uuid' => $uuid, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => $e->getMessage() ?: 'Gagal semak status BSAS.', + ], 422); + } + } + } diff --git a/app/Services/Bsas/BsasClient.php b/app/Services/Bsas/BsasClient.php new file mode 100644 index 0000000..9f64b5e --- /dev/null +++ b/app/Services/Bsas/BsasClient.php @@ -0,0 +1,193 @@ +http() + ->asForm() + ->post($tokenUrl, [ + 'grant_type' => 'client_credentials', + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]); + + $payload = $response->json(); + if (!is_array($payload)) { + $payload = []; + } + + if (!$response->successful()) { + Log::error('BSAS OAuth token request failed', [ + 'status' => $response->status(), + 'body' => $payload ?: $response->body(), + ]); + throw new RuntimeException('Gagal mendapatkan BSAS access token.'); + } + + $token = $payload['access_token'] ?? null; + if (!is_string($token) || $token === '') { + Log::error('BSAS OAuth response missing string access_token', [ + 'body' => $payload, + ]); + throw new RuntimeException('BSAS OAuth response missing access_token.'); + } + + $expiresIn = (int) ($payload['expires_in'] ?? self::TOKEN_CACHE_SECONDS); + $ttl = max(60, min(self::TOKEN_CACHE_SECONDS, $expiresIn - 300)); + Cache::put(self::TOKEN_CACHE_KEY, $token, $ttl); + + return $token; + } + + public function placeOrder(array $payload): array + { + return $this->requestJson('post', '/process/svc/bsas/order', $payload); + } + + public function orderResult(string $uuid, array $body = []): array + { + $maxWait = (string) ($body['maxWaitTime'] ?? config('bsas.max_wait_time', 10)); + + $payload = [ + 'header' => [ + 'memberShortName' => config('bsas.member_short_name'), + 'uuid' => $uuid, + ], + 'body' => [ + 'serialNumbers' => (string) ($body['serialNumbers'] ?? '1'), + 'forceYN' => (string) ($body['forceYN'] ?? 'Y'), + 'maxWaitTime' => $maxWait, + 'waitAllDoneYN' => (string) ($body['waitAllDoneYN'] ?? 'Y'), + ], + ]; + + // Long poll: client timeout must exceed BSAS maxWaitTime. + $timeout = max( + (int) config('bsas.http_timeout', 30), + (int) $maxWait + 20 + ); + + return $this->requestJson('get', '/process/svc/bsas/orderResult', $payload, $timeout); + } + + public function getBidEcert(string $ecertNo): array + { + return $this->requestEcert('/process/svc/bsas/bidXML', $ecertNo); + } + + public function getOtcEcert(string $ecertNo): array + { + return $this->requestEcert('/process/svc/bsas/otcXML', $ecertNo); + } + + public function getStbEcert(string $ecertNo): array + { + return $this->requestEcert('/process/svc/bsas/stbXML', $ecertNo); + } + + protected function requestEcert(string $path, string $ecertNo): array + { + $payload = [ + 'input' => [ + 'ecertno' => $ecertNo, + 'membershortname' => config('bsas.member_short_name'), + ], + ]; + + return $this->requestJson('get', $path, $payload); + } + + protected function requestJson(string $method, string $path, array $payload, ?int $timeout = null): array + { + $url = rtrim(config('bsas.base_url'), '/') . '/' . ltrim($path, '/'); + $timeout = $timeout ?? (int) config('bsas.http_timeout', 30); + $token = $this->getAccessToken(); + + $pending = $this->http($timeout) + ->withToken($token) + ->acceptJson(); + + if (strtolower($method) === 'get') { + $response = $pending + ->withHeaders(['Content-Type' => 'application/json']) + ->send('GET', $url, [ + 'body' => json_encode($payload), + ]); + } else { + $response = $pending->asJson()->post($url, $payload); + } + + $json = $response->json(); + if (!is_array($json)) { + $body = $response->body(); + $cloudflare = stripos($body, 'cloudflare') !== false || stripos($body, 'Attention Required') !== false; + + if ($cloudflare) { + throw new RuntimeException( + 'BSAS blocked by Cloudflare (check IP whitelist). HTTP ' . $response->status() + ); + } + + throw new RuntimeException('BSAS returned invalid JSON response.'); + } + + if (!$response->successful()) { + Log::error('BSAS HTTP error', [ + 'method' => $method, + 'url' => $url, + 'status' => $response->status(), + 'body' => $json, + ]); + throw new RuntimeException('BSAS request failed (HTTP ' . $response->status() . ').'); + } + + Log::info('BSAS API call', [ + 'method' => $method, + 'path' => $path, + 'status' => $response->status(), + 'header' => $json['header'] ?? null, + ]); + + return $json; + } + + protected function http(?int $timeout = null): PendingRequest + { + $timeout = $timeout ?? (int) config('bsas.http_timeout', 30); + + return Http::withOptions([ + 'timeout' => $timeout, + 'connect_timeout' => min(15, $timeout), + ]); + } +} diff --git a/app/Services/Bsas/BsasOrderService.php b/app/Services/Bsas/BsasOrderService.php new file mode 100644 index 0000000..94fa585 --- /dev/null +++ b/app/Services/Bsas/BsasOrderService.php @@ -0,0 +1,374 @@ +client = $client ?: new BsasClient(); + } + + /** + * Place BSAS order for a gadai Terima, long-poll result, fetch eCert. + * + * @param array $input norujukan, kodcaw, pinjaman, hargajualan, nama_pelanggan, teller + * @return BsasOrder + */ + public function placeAndConfirm(array $input): BsasOrder + { + $norujukan = (string) ($input['norujukan'] ?? ''); + $kodcaw = (string) ($input['kodcaw'] ?? ''); + $pinjaman = round((float) ($input['pinjaman'] ?? 0), 2); + $hargajualan = round((float) ($input['hargajualan'] ?? 0), 2); + $counterParty = trim((string) ($input['nama_pelanggan'] ?? '')); + $createdBy = (string) ($input['teller'] ?? ''); + + if ($norujukan === '' || $kodcaw === '' || $pinjaman <= 0) { + throw new RuntimeException('BSAS order requires norujukan, kodcaw and pinjaman.'); + } + + $existing = BsasOrder::on('mysql7') + ->where('norujukan', $norujukan) + ->orderBy('id', 'desc') + ->first(); + + if ($existing && $existing->status === 'confirmed') { + if ($this->needsEcertFetch($existing)) { + $this->fetchAndStoreEcert($existing); + } + $this->ensureKomuditiTransaksi($existing, $createdBy); + return $existing->fresh(); + } + + if ($existing && in_array($existing->status, ['pending', 'submitted'], true) && !empty($existing->uuid)) { + return $this->completeSubmittedOrder($existing, $createdBy); + } + + $serial = '1'; + $payload = $this->buildOrderPayload($pinjaman, $hargajualan, $counterParty, $serial); + + $order = BsasOrder::on('mysql7')->create([ + 'uuid' => null, + 'norujukan' => $norujukan, + 'kodcaw' => $kodcaw, + 'serial_number' => $serial, + 'product_code' => config('bsas.product_code'), + 'bid_value' => $pinjaman, + 'otc_murabaha_value' => $hargajualan, + 'otc_counter_party' => $counterParty, + 'status' => 'pending', + 'request_payload' => $payload, + 'created_by' => $createdBy, + ]); + + try { + $orderResponse = $this->client->placeOrder($payload); + } catch (\Throwable $e) { + $order->status = 'failed'; + $order->status_message = $e->getMessage(); + $order->save(); + throw $e; + } + + $order->order_response = $orderResponse; + $uuid = data_get($orderResponse, 'header.uuid'); + $errorCode = trim((string) data_get($orderResponse, 'header.errorCode', '')); + $errorMsg = trim((string) data_get($orderResponse, 'header.errorMsg', '')); + + if ($errorCode !== '' || !$uuid) { + $order->status = 'failed'; + $order->status_message = $errorMsg !== '' ? $errorMsg : 'BSAS order rejected (missing uuid or errorCode set).'; + $order->save(); + throw new RuntimeException($order->status_message); + } + + $bodyStatus = data_get($orderResponse, 'body.0.statusCode'); + if ($bodyStatus !== null && (int) $bodyStatus !== 0) { + $order->uuid = $uuid; + $order->status = 'failed'; + $order->status_code = (int) $bodyStatus; + $order->status_message = (string) data_get($orderResponse, 'body.0.statusMessage', 'BSAS order body statusCode != 0'); + $order->save(); + throw new RuntimeException($order->status_message); + } + + $order->uuid = $uuid; + $order->status = 'submitted'; + $order->save(); + + return $this->completeSubmittedOrder($order, $createdBy); + } + + /** + * Re-query orderResult / eCert for an existing uuid. + */ + public function refreshByUuid(string $uuid): BsasOrder + { + $order = BsasOrder::on('mysql7')->where('uuid', $uuid)->first(); + if (!$order) { + throw new RuntimeException('BSAS order not found for uuid.'); + } + + if ($order->status === 'confirmed') { + if ($this->needsEcertFetch($order)) { + $this->fetchAndStoreEcert($order); + } + return $order->fresh(); + } + + return $this->completeSubmittedOrder($order, (string) $order->created_by); + } + + protected function completeSubmittedOrder(BsasOrder $order, string $createdBy): BsasOrder + { + if (empty($order->uuid)) { + throw new RuntimeException('BSAS order has no uuid to poll.'); + } + + $result = $this->longPollOrderResult($order->uuid, (string) $order->serial_number); + $order->result_response = $result; + + $headerError = trim((string) data_get($result, 'header.errorCode', '')); + $headerMsg = trim((string) data_get($result, 'header.errorMsg', '')); + $statusCode = data_get($result, 'body.0.statusCode'); + $statusMessage = (string) data_get($result, 'body.0.statusMessage', ''); + $bidErrNo = trim((string) data_get($result, 'body.0.bidErrNo', '')); + $otcErrNo = trim((string) data_get($result, 'body.0.otcErrNo', '')); + $stbErrNo = trim((string) data_get($result, 'body.0.stbErrNo', '')); + $bidMsg = trim((string) data_get($result, 'body.0.bidMsg', '')); + $otcMsg = trim((string) data_get($result, 'body.0.otcMsg', '')); + $stbMsg = trim((string) data_get($result, 'body.0.stbMsg', '')); + $ecertNo = $this->extractEcertNo($result, $order->order_response); + + if ($headerError !== '') { + $order->status = 'failed'; + $order->status_code = is_numeric($statusCode) ? (int) $statusCode : null; + $order->status_message = $headerMsg !== '' ? $headerMsg : 'BSAS orderResult header errorCode set.'; + $order->save(); + throw new RuntimeException($order->status_message); + } + + // Live BSAS result often has no statusCode; success is ecertNo and/or bid/otc/stb ErrNo=999 (OK). + $successByStatusCode = $statusCode !== null && (int) $statusCode === 0; + $successByErrNo = ($bidErrNo === '999' || strtoupper($bidMsg) === 'OK') + && ($otcErrNo === '999' || strtoupper($otcMsg) === 'OK') + && ($stbErrNo === '999' || strtoupper($stbMsg) === 'OK'); + $successByEcert = $ecertNo !== null && $ecertNo !== ''; + + if (!$successByStatusCode && !$successByErrNo && !$successByEcert) { + if ($statusCode !== null && (int) $statusCode !== 0) { + $order->status = 'failed'; + $order->status_code = (int) $statusCode; + $order->status_message = $statusMessage !== '' ? $statusMessage : 'BSAS orderResult statusCode != 0'; + $order->save(); + throw new RuntimeException($order->status_message); + } + + $order->status = 'submitted'; + $order->status_message = 'BSAS orderResult belum selesai / tiada ecert.'; + $order->save(); + throw new RuntimeException('BSAS orderResult belum selesai. Sila cuba semula.'); + } + + $order->status_code = $statusCode !== null ? (int) $statusCode : 0; + $order->status_message = $statusMessage !== '' ? $statusMessage : ($bidMsg ?: 'OK'); + $order->status = 'confirmed'; + if ($ecertNo) { + $order->e_cert_no = $ecertNo; + } + $order->save(); + + if ($this->needsEcertFetch($order)) { + $this->fetchAndStoreEcert($order); + } + + if (empty($order->e_cert_no)) { + Log::warning('BSAS order confirmed but ecertno missing', [ + 'norujukan' => $order->norujukan, + 'uuid' => $order->uuid, + ]); + } + + $this->ensureKomuditiTransaksi($order, $createdBy); + + return $order->fresh(); + } + + protected function longPollOrderResult(string $uuid, string $serial): array + { + $retries = max(1, (int) config('bsas.order_result_retries', 2)); + $last = []; + + for ($i = 0; $i < $retries; $i++) { + $last = $this->client->orderResult($uuid, [ + 'serialNumbers' => $serial, + 'forceYN' => 'Y', + 'maxWaitTime' => (string) config('bsas.max_wait_time', 10), + 'waitAllDoneYN' => 'Y', + ]); + + // Done when statusCode is set, or bid, OTC and STB have all reported. + $bidDone = trim((string) data_get($last, 'body.0.bidErrNo', '')) !== ''; + $otcDone = trim((string) data_get($last, 'body.0.otcErrNo', '')) !== ''; + $stbDone = trim((string) data_get($last, 'body.0.stbErrNo', '')) !== ''; + $hasEcert = trim((string) data_get($last, 'body.0.ecertNo', '')) !== '' + || trim((string) data_get($last, 'body.0.eCertNo', '')) !== ''; + + if ( + data_get($last, 'body.0.statusCode') !== null + || ($bidDone && $otcDone && $stbDone) + || ($hasEcert && $stbDone) + ) { + return $last; + } + } + + return $last; + } + + protected function needsEcertFetch(BsasOrder $order): bool + { + return empty($order->bid_ecert_response) + || empty($order->otc_ecert_response) + || empty($order->stb_ecert_response); + } + + protected function fetchAndStoreEcert(BsasOrder $order): void + { + $ecertNo = $order->e_cert_no; + if (!$ecertNo) { + $ecertNo = $this->extractEcertNo($order->result_response, $order->order_response); + if ($ecertNo) { + $order->e_cert_no = $ecertNo; + $order->save(); + } + } + + if (!$ecertNo) { + return; + } + + $calls = [ + 'bid_ecert_response' => 'getBidEcert', + 'otc_ecert_response' => 'getOtcEcert', + 'stb_ecert_response' => 'getStbEcert', + ]; + + foreach ($calls as $column => $method) { + if (!empty($order->{$column})) { + continue; + } + + try { + $ecert = $this->client->{$method}($ecertNo); + $order->{$column} = $ecert; + if ($column === 'otc_ecert_response' && empty($order->e_cert_no) && !empty($ecert['ECERTNO'])) { + $order->e_cert_no = $ecert['ECERTNO']; + } + $order->save(); + } catch (\Throwable $e) { + Log::error('BSAS eCert fetch failed after confirmed order', [ + 'norujukan' => $order->norujukan, + 'uuid' => $order->uuid, + 'e_cert_no' => $ecertNo, + 'certificate' => $column, + 'error' => $e->getMessage(), + ]); + } + } + } + + protected function ensureKomuditiTransaksi(BsasOrder $order, string $teller): void + { + $exists = KomuditiTransaksi::on('mysql7') + ->where('norujukan', $order->norujukan) + ->first(); + + if ($exists) { + return; + } + + $current = Carbon::now(); + KomuditiTransaksi::on('mysql7')->create([ + 'unit_komuditi' => round((float) $order->bid_value, 2), + 'nosijil' => $order->e_cert_no ?: $order->uuid, + 'norujukan' => $order->norujukan, + 'kodcaw' => $order->kodcaw, + 'teller' => $teller !== '' ? $teller : $order->created_by, + 'masa' => $current->toTimeString(), + 'tarikh' => $current->toDateString(), + ]); + } + + protected function buildOrderPayload(float $pinjaman, float $hargajualan, string $counterParty, string $serial): array + { + return [ + 'header' => [ + 'memberShortName' => config('bsas.member_short_name'), + 'uuid' => $this->uuidV4(), + ], + 'body' => [ + 'serialNumber' => $serial, + 'bidOption' => 'Y', + 'otcOption' => 'Y', + 'stbOption' => 'Y', + 'productCode' => config('bsas.product_code'), + 'purchaseType' => 'P', + 'clientName' => '', + 'currency' => 'MYR', + 'bidValue' => number_format($pinjaman, 2, '.', ''), + 'valueDate' => Carbon::now()->format('Ymd'), + 'tenor' => config('bsas.tenor'), + 'otcCounterParty' => $counterParty, + 'otcMurabaha' => '', + 'otcMurabahaValue' => number_format($hargajualan, 2, '.', ''), + 'eCertNo' => '', + ], + ]; + } + + /** + * Best-effort extract ecert number from result/order payloads. + */ + protected function extractEcertNo($result, $orderResponse): ?string + { + $candidates = [ + data_get($result, 'body.0.eCertNo'), + data_get($result, 'body.0.ecertNo'), + data_get($result, 'body.0.ECERTNO'), + data_get($result, 'body.0.ecertno'), + data_get($result, 'body.0.certificateNo'), + data_get($orderResponse, 'body.0.eCertNo'), + data_get($orderResponse, 'body.0.ecertNo'), + data_get($orderResponse, 'body.0.ECERTNO'), + ]; + + foreach ($candidates as $value) { + $value = trim((string) $value); + if ($value !== '') { + return $value; + } + } + + return null; + } + + protected function uuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 26b6559..a2b3ce0 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -96,6 +96,7 @@ $app->configure('app_parameter'); $app->configure('parameter'); $app->configure('whatsapp'); $app->configure('kaunter'); +$app->configure('bsas'); /* |-------------------------------------------------------------------------- | Register Middleware diff --git a/config/bsas.php b/config/bsas.php new file mode 100644 index 0000000..d7b08eb --- /dev/null +++ b/config/bsas.php @@ -0,0 +1,14 @@ + rtrim(env('BSAS_BASE_URL', 'https://bsasdevapi.bursamalaysia.com/api'), '/'), + 'token_url' => env('BSAS_TOKEN_URL', 'https://bsasdevapi.bursamalaysia.com/oauth/token'), + 'client_id' => env('BSAS_CLIENT_ID'), + 'client_secret' => env('BSAS_CLIENT_SECRET'), + 'member_short_name' => env('BSAS_MEMBER_SHORT_NAME', 'KOOPPKB'), + 'product_code' => env('BSAS_PRODUCT_CODE', 'CPO-MSIA-09'), + 'tenor' => env('BSAS_TENOR', '00088'), + 'max_wait_time' => (int) env('BSAS_MAX_WAIT_TIME', 10), + 'http_timeout' => (int) env('BSAS_HTTP_TIMEOUT', 30), + 'order_result_retries' => (int) env('BSAS_ORDER_RESULT_RETRIES', 2), +]; diff --git a/database/migrations/2026_10_13_160000_create_bsas_orders_table.php b/database/migrations/2026_10_13_160000_create_bsas_orders_table.php new file mode 100644 index 0000000..35ff155 --- /dev/null +++ b/database/migrations/2026_10_13_160000_create_bsas_orders_table.php @@ -0,0 +1,49 @@ +hasTable('bsas_orders')) { + return; + } + + Schema::connection('mysql7')->create('bsas_orders', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('uuid', 64)->nullable()->unique(); + $table->string('norujukan', 50)->index(); + $table->string('kodcaw', 20)->index(); + $table->string('serial_number', 20)->default('1'); + $table->string('product_code', 50)->nullable(); + $table->decimal('bid_value', 14, 2)->nullable(); + $table->decimal('otc_murabaha_value', 14, 2)->nullable(); + $table->string('otc_counter_party', 150)->nullable(); + $table->string('status', 30)->default('pending')->index(); + $table->integer('status_code')->nullable(); + $table->string('status_message', 255)->nullable(); + $table->string('e_cert_no', 100)->nullable()->index(); + $table->json('bid_ecert_response')->nullable(); + $table->json('otc_ecert_response')->nullable(); + $table->json('stb_ecert_response')->nullable(); + $table->json('request_payload')->nullable(); + $table->json('order_response')->nullable(); + $table->json('result_response')->nullable(); + $table->string('created_by', 100)->nullable(); + $table->timestamps(); + + $table->index(['norujukan', 'status']); + }); + } + + public function down() + { + Schema::connection('mysql7')->dropIfExists('bsas_orders'); + } +} diff --git a/routes/web.php b/routes/web.php index 982afc3..d1878ec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -665,6 +665,13 @@ $router->group(['prefix'=>'api'], function () use ($router){ $router->put('komuditi/transaksi',['uses'=>'KomuditiController@transaksiKomuditi']); $router->put('komuditi/batal',['uses'=>'KomuditiController@batalKomuditi']); + // BSAS per-Terima commodity order + $router->get('komuditi/bsas/orders',['uses'=>'KomuditiController@listBsasOrders']); + $router->post('komuditi/bsas/order',['uses'=>'KomuditiController@placeBsasOrder']); + $router->get('komuditi/bsas/order/norujukan/{norujukan}',['uses'=>'KomuditiController@getBsasOrderByNorujukan']); + $router->get('komuditi/bsas/order/{uuid}/ecert',['uses'=>'KomuditiController@getBsasOrderEcert']); + $router->get('komuditi/bsas/order/{uuid}',['uses'=>'KomuditiController@getBsasOrder']); + $router->get('komuditi/produk',['uses'=>'KomuditiController@getKomuditiProduct']); $router->get('komuditi/unit',['uses'=>'KomuditiController@getKomuditiProductUnit']); $router->get('komuditi/description',['uses'=>'KomuditiController@getKomuditiProductDescription']);