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