167 lines
5.9 KiB
PHP
167 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
|
|
class ShorakaService
|
|
{
|
|
const STATUSES = ['Active', 'Completed', 'Cancelled', 'Pending Sell', 'Take Delivery'];
|
|
|
|
const CALLBACK_FIELDS = [
|
|
'orderId', 'status', 'bankName', 'ownershipName', 'commodityType',
|
|
'unit', 'volume', 'productType', 'valueDate', 'cancelDate', 'orderType',
|
|
'orderCurrency', 'orderAmount', 'murabahaAmount', 'tenor',
|
|
'tenorOther', 'tenorOtherUnit',
|
|
];
|
|
|
|
protected $baseUrl;
|
|
protected $apiId;
|
|
protected $secretKey;
|
|
protected $timeout;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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
|
|
{
|
|
return $this->apiId !== '' && $this->secretKey !== '';
|
|
}
|
|
|
|
public function submitOrder(array $data): array
|
|
{
|
|
$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
|
|
{
|
|
return $this->orderResponse($this->request()->post(
|
|
$this->baseUrl . '/api/orderstatus', $this->orderPayload($orderId)
|
|
), $orderId);
|
|
}
|
|
|
|
public function certificate(string $orderId): string
|
|
{
|
|
// 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->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;
|
|
}
|
|
}
|