diff --git a/.github/workflows/php-cs.yml b/.github/workflows/php-cs.yml new file mode 100644 index 0000000..7c09cdd --- /dev/null +++ b/.github/workflows/php-cs.yml @@ -0,0 +1,36 @@ +on: + push: + paths: + - '**.php' + pull_request: + paths: + - '**.php' + +name: phpcs + +jobs: + phpcs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: check php version + run: php -v + + - name: download phpcs + run: wget -O phpcs.phar https://cs.symfony.com/download/php-cs-fixer-v3.phar + + - name: make phpcs executable + run: chmod +x phpcs.phar + + - name: apply coding style + run: php phpcs.phar fix + + - name: remove phpcs + run: rm -f phpcs.phar + + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: 'phpcs: apply coding style' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ac0100 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor +/example.php +/composer.lock +/notify.php +/notify.log \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..4a2f428 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +in(__DIR__) + ->exclude($directories); + +return (new Config()) + ->setRules([ + '@PSR2' => true, + ]) + ->setRiskyAllowed(true) + ->setUsingCache(false) + ->setFinder($finder); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..929928c --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "zerosdev/tripay-sdk-php", + "description": "Unofficial TriPay.co.id Integration Kit for PHP", + "type": "library", + "require": { + "php": ">=7.2.0", + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5|^7.0" + }, + "license": "MIT", + "authors": [ + { + "name": "ZerosDev", + "email": "ronywisnuwardana@gmail.com" + } + ], + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "ZerosDev\\TriPay\\": "src/" + } + } +} diff --git a/src/Callback.php b/src/Callback.php new file mode 100644 index 0000000..365a44d --- /dev/null +++ b/src/Callback.php @@ -0,0 +1,109 @@ +client = $client; + $this->json = (string) file_get_contents("php://input"); + $this->parsedJson = json_decode($this->json); + + if ($verifyOnLoad) { + $this->validate(); + } + } + + /** + * Get local signature + * + * @return string + */ + public function localSignature(): string + { + return (string) hash_hmac('sha256', $this->json, $this->client->privateKey); + } + + /** + * Get incoming signature + * + * @return string|null + */ + public function incomingSignature(): ?string + { + return (string) (isset($_SERVER['HTTP_X_CALLBACK_SIGNATURE']) ? $_SERVER['HTTP_X_CALLBACK_SIGNATURE'] : ""); + } + + /** + * Validate incoming signature + * + * @return boolean + * @throws SignatureException + * @throws UnexpectedValueException + */ + public function validate(): bool + { + $validSignature = hash_equals( + $this->localSignature(), + $this->incomingSignature() + ); + + if (!$validSignature) { + throw new SignatureException('Incoming signature does not match local signature'); + } + + $validData = !is_null($this->data()); + + if (!$validData) { + throw new UnexpectedValueException('Callback data is invalid'); + } + + return true; + } + + /** + * Parse JSON data + * + * @return object|null + */ + public function data(): ?object + { + return $this->parsedJson; + } +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..45e08f7 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,192 @@ + null, + 'response' => null, + ]; + + /** + * Required configuration key + * + * @var array + */ + private array $requiredConfigKeys = [ + 'merchant_code', + 'api_key', + 'private_key', + 'mode' + ]; + + /** + * Reserved guzzle options that can't be overrided + * + * @var array + */ + private array $reservedGuzzleOptions = [ + 'base_uri', + 'on_stats', + ]; + + /** + * HTTP Client instance + * + * @var GuzzleHttp\Client as HttpClient + */ + private HttpClient $client; + + /** + * Client instance + * + * You can use array config with the following keys + * [ + * 'merchant_id' => '', + * 'api_key' => '', + * 'private_key' => '', + * 'mode => '', + * 'guzzle_options' => [] + * ] + * + * or use the positional arguments in the following sequence + * $merchantId, $apiKey, $privateKey, $mode, $guzzleOptions + * + * @param array|string ...$args + * @throws InvalidArgumentException + */ + public function __construct(...$args) + { + if (is_array($args[0])) { + foreach ($this->requiredConfigKeys as $configKey) { + if (!isset($args[0][$configKey])) { + throw new InvalidArgumentException("`{$configKey}` must be in the configuration value"); + } + } + } else { + foreach ($this->requiredConfigKeys as $key => $configKey) { + if (!isset($args[$key])) { + throw new InvalidArgumentException("`{$configKey}` must be in the configuration value"); + } + } + } + + $this->merchantCode = (string) is_array($args[0]) ? $args[0]['merchant_code'] : $args[0]; + $this->apiKey = (string) is_array($args[0]) ? $args[0]['api_key'] : $args[1]; + $this->privateKey = (string) is_array($args[0]) ? $args[0]['private_key'] : $args[1]; + $this->mode = (string) is_array($args[0]) ? $args[0]['mode'] : $args[2]; + + $baseUri = ($this->mode == Constant::MODE_DEVELOPMENT) + ? Constant::URL_DEVELOPMENT + : Constant::URL_PRODUCTION; + + $options = [ + 'base_uri' => $baseUri, + 'http_errors' => false, + 'connect_timeout' => 10, + 'timeout' => 50, + 'on_stats' => function (TransferStats $stats) { + $hasResponse = $stats->hasResponse(); + $this->debugs = array_merge($this->debugs, [ + 'request' => [ + 'url' => (string) $stats->getEffectiveUri(), + 'method' => $stats->getRequest()->getMethod(), + 'headers' => (array) $stats->getRequest()->getHeaders(), + 'body' => (string) $stats->getRequest()->getBody(), + ], + 'response' => [ + 'status' => (int) ($hasResponse ? $stats->getResponse()->getStatusCode() : 0), + 'headers' => (array) ($hasResponse ? $stats->getResponse()->getHeaders() : []), + 'body' => (string) ($hasResponse ? $stats->getResponse()->getBody() : ""), + ], + ]); + }, + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->apiKey, + 'User-Agent' => 'zerosdev/tripay-sdk-php', + ] + ]; + + $guzzleOptions = (array) is_array($args[0]) ? ($args[0]['guzzle_options'] ?? []) : ($args[3] ?? []); + + foreach ($this->reservedGuzzleOptions as $reserved) { + unset($guzzleOptions[$reserved]); + } + + $options = array_merge($options, $guzzleOptions); + + $this->client = $this->createHttpClient($options); + } + + private function createHttpClient(array $options): HttpClient + { + return new HttpClient($options); + } + + public function get($endpoint, array $headers = []): Response + { + return $this->client->get($endpoint, [ + 'headers' => $headers, + ]); + } + + public function post($endpoint, array $payloads, array $headers = []): Response + { + return $this->client->post($endpoint, [ + 'json' => $payloads, + 'headers' => $headers, + ]); + } + + public function debugs(): object + { + return (object) $this->debugs; + } +} diff --git a/src/Exception/SignatureException.php b/src/Exception/SignatureException.php new file mode 100644 index 0000000..fb9822d --- /dev/null +++ b/src/Exception/SignatureException.php @@ -0,0 +1,10 @@ +client = $client; + } + + /** + * Get enabled payment channels + * + * @return Response + */ + public function paymentChannels(): Response + { + return $this->client->get('merchant/payment-channel'); + } + + /** + * Get fee calculation + * + * @param mixed $amount + * @param string|null $channelCode + * @return Response + */ + public function feeCalculator($amount, ?string $channelCode = null): Response + { + $payloads = [ + 'amount' => Helper::formatAmount($amount), + ]; + + if (!empty($channelCode)) { + $payloads['code'] = $channelCode; + } + + return $this->client->get('merchant/fee-calculator?' . http_build_query($payloads)); + } + + /** + * Get merchant transactions + * + * @param array $payloads + * @return Response + */ + public function transactions(array $payloads = []): Response + { + return $this->client->get('merchant/transactions?' . http_build_query($payloads)); + } +} diff --git a/src/OpenPayment.php b/src/OpenPayment.php new file mode 100644 index 0000000..82c433e --- /dev/null +++ b/src/OpenPayment.php @@ -0,0 +1,68 @@ +client = $client; + } + + /** + * Create open payment + * + * @param array $payloads + * @return Response + * @throws InvalidArgumentException + */ + public function create(array $payloads): Response + { + unset($payloads['signature']); + + Helper::checkRequiredPayloads([ + 'method' + ], $payloads); + + $payloads['signature'] = Helper::makeOpenPaymentSignature($this->client, $payloads); + + return $this->client->post('open-payment/create', $payloads); + } + + /** + * Get open payment detail + * + * @param string $uuid + * @return Response + */ + public function detail(string $uuid): Response + { + return $this->client->get('open-payment/' . $uuid . '/detail'); + } + + /** + * Get open payment transactions + * + * @param string $uuid + * @return Response + */ + public function transactions(string $uuid): Response + { + return $this->client->get('open-payment/' . $uuid . '/transactions'); + } +} diff --git a/src/Payment.php b/src/Payment.php new file mode 100644 index 0000000..3a40424 --- /dev/null +++ b/src/Payment.php @@ -0,0 +1,56 @@ +client = $client; + } + + /** + * Get payment channel instruction + * + * @param string $channelCode + * @param string|null $payCode + * @param mixed $amount + * @param integer|null $allowHtml + * @return Response + */ + public function instruction(string $channelCode, ?string $payCode = null, $amount = null, ?int $allowHtml = null): Response + { + $payloads = [ + 'code' => $channelCode + ]; + + if (!is_null($payCode)) { + $payloads['pay_code'] = $payCode; + } + + if (!is_null($amount)) { + $payloads['amount'] = $amount; + } + + if (!is_null($allowHtml)) { + $payloads['allow_html'] = $allowHtml; + } + + return $this->client->get('payment/instruction?' . http_build_query($payloads)); + } +} diff --git a/src/Support/Constant.php b/src/Support/Constant.php new file mode 100644 index 0000000..7924f5c --- /dev/null +++ b/src/Support/Constant.php @@ -0,0 +1,12 @@ +merchantCode . $merchantRef . $amount, $client->privateKey); + } + + /** + * Create open payment signature + * + * @param ZerosDev\TriPay\Client $client + * @param array $payloads + * @return string + */ + public static function makeOpenPaymentSignature(Client $client, array $payloads): string + { + $method = isset($payloads['method']) ? $payloads['method'] : null; + $merchantRef = isset($payloads['merchant_ref']) ? $payloads['merchant_ref'] : null; + + return hash_hmac('sha256', $client->merchantCode . $method . $merchantRef, $client->privateKey); + } + + /** + * Format amount to integer + * + * @param mixed $amount + * @return int + */ + public static function formatAmount($amount): int + { + if (!is_numeric($amount)) { + throw new Exception('Amount must be numeric value'); + } + + return (int) number_format($amount, 0, '', ''); + } + + /** + * Check required key in payloads + * + * @param array $requireds + * @param array $payloads + * @return void + * @throws InvalidArgumentException + */ + public static function checkRequiredPayloads(array $requireds, array $payloads): void + { + foreach ($requireds as $req) { + if (!isset($payloads[$req]) || empty($payloads[$req])) { + throw new InvalidArgumentException("`{$req}` must be filled in payloads"); + } + } + } +} diff --git a/src/Transaction.php b/src/Transaction.php new file mode 100644 index 0000000..3879029 --- /dev/null +++ b/src/Transaction.php @@ -0,0 +1,98 @@ +client = $client; + } + + /** + * Add order items to payloads + * + * @param string $name + * @param integer $price + * @param integer $quantity + * @param string|null $sku + * @param string|null $product_url + * @param string|null $image_url + * @return self + */ + public function addOrderItem(string $name, int $price, int $quantity, ?string $sku = null, ?string $product_url = null, ?string $image_url = null): self + { + $this->order_items[] = [ + 'sku' => $sku, + 'name' => $name, + 'price' => $price, + 'quantity' => $quantity, + 'product_url' => $product_url, + 'image_url' => $image_url, + ]; + + return $this; + } + + /** + * Create transaction + * + * @param array $payloads + * @return Response + * @throws InvalidArgumentException + */ + public function create(array $payloads): Response + { + unset($payloads['signature']); + + $payloads['order_items'] = isset($payloads['order_items']) + ? array_merge($payloads['order_items'], $this->order_items) + : $this->order_items; + + $payloads['amount'] = 0; + foreach ($this->order_items as $orderItem) { + $payloads['amount'] += $orderItem['price'] * $orderItem['quantity']; + } + + Helper::checkRequiredPayloads([ + 'method', 'customer_name', 'customer_email', 'order_items' + ], $payloads); + + $payloads['signature'] = Helper::makeSignature($this->client, $payloads); + + return $this->client->post('transaction/create', $payloads); + } + + /** + * Get transaction detail + * + * @param string $reference + * @return Response + */ + public function detail(string $reference): Response + { + return $this->client->get('transaction/detail?reference=' . $reference); + } +}