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