> - The defaults configuration whose pased in `GuzzleHttp\Client`. */ protected static $defaults = [ 'base_uri' => 'https://api.mch.weixin.qq.com/', 'headers' => [ 'Accept' => 'application/json, text/plain, application/x-gzip, application/pdf, image/png, image/*;q=0.5', 'Content-Type' => 'application/json; charset=utf-8', ], ]; abstract protected static function body(MessageInterface $message): string; abstract protected static function withDefaults(array ...$config): array; /** * APIv3's signer middleware stack * * @param string $mchid - The merchant ID * @param string $serial - The serial number of the merchant certificate * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string $privateKey - The merchant private key. * * @return callable(RequestInterface) */ public static function signer(string $mchid, string $serial, $privateKey): callable { return static function (RequestInterface $request) use ($mchid, $serial, $privateKey): RequestInterface { $nonce = Formatter::nonce(); $timestamp = (string) Formatter::timestamp(); $signature = Crypto\Rsa::sign(Formatter::request( $request->getMethod(), $request->getRequestTarget(), $timestamp, $nonce, static::body($request) ), $privateKey); return $request->withHeader('Authorization', Formatter::authorization( $mchid, $nonce, $signature, $timestamp, $serial )); }; } /** * Assert the HTTP `20X` responses fit for the business logic, otherwise thrown a `\GuzzleHttp\Exception\RequestException`. * * The `30X` responses were handled by `\GuzzleHttp\RedirectMiddleware`. * The `4XX, 5XX` responses were handled by `\GuzzleHttp\Middleware::httpErrors`. * * @param array $certs The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair * @return callable(ResponseInterface,RequestInterface) * @throws RequestException */ protected static function assertSuccessfulResponse(array &$certs): callable { return static function (ResponseInterface $response, RequestInterface $request) use(&$certs): ResponseInterface { if ( 0 === strcasecmp($url = $request->getUri()->getPath(), '/v3/billdownload/file') || (0 === strncasecmp($url, '/v3/merchant-service/images/', 28) && 0 !== strcasecmp($url, '/v3/merchant-service/images/upload')) ) { return $response; } if (!($response->hasHeader(WechatpayNonce) && $response->hasHeader(WechatpaySerial) && $response->hasHeader(WechatpaySignature) && $response->hasHeader(WechatpayTimestamp))) { throw new RequestException(sprintf( Exception\WeChatPayException::EV3_RES_HEADERS_INCOMPLETE, WechatpayNonce, WechatpaySerial, WechatpaySignature, WechatpayTimestamp ), $request, $response); } [$nonce] = $response->getHeader(WechatpayNonce); [$serial] = $response->getHeader(WechatpaySerial); [$signature] = $response->getHeader(WechatpaySignature); [$timestamp] = $response->getHeader(WechatpayTimestamp); $localTimestamp = Formatter::timestamp(); if (abs($localTimestamp - intval($timestamp)) > MAXIMUM_CLOCK_OFFSET) { throw new RequestException(sprintf( Exception\WeChatPayException::EV3_RES_HEADER_TIMESTAMP_OFFSET, MAXIMUM_CLOCK_OFFSET, $timestamp, $localTimestamp ), $request, $response); } if (!array_key_exists($serial, $certs)) { throw new RequestException(sprintf( Exception\WeChatPayException::EV3_RES_HEADER_PLATFORM_SERIAL, $serial, WechatpaySerial, implode(',', array_keys($certs)) ), $request, $response); } $isOverseas = (0 === strcasecmp($url, '/hk/v3/statements') || 0 === strcasecmp($url, '/v3/global/statements')) && $response->hasHeader(WechatpayStatementSha1); $verified = false; try { $verified = Crypto\Rsa::verify( Formatter::response( $timestamp, $nonce, $isOverseas ? static::digestBody($response) : static::body($response) ), $signature, $certs[$serial] ); } catch (\Exception $exception) {} if ($verified === false) { throw new RequestException(sprintf( Exception\WeChatPayException::EV3_RES_HEADER_SIGNATURE_DIGEST, $timestamp, $nonce, $signature, $serial ), $request, $response, $exception ?? null); } return $response; }; } /** * Downloading the reconciliation was required the client to format the `WechatpayStatementSha1` digest string as `JSON`. * * There was also sugguestion that to validate the response streaming's `SHA1` digest whether or nor equals to `WechatpayStatementSha1`. * Here may contains with or without `gzip` parameter. Both of them are validating the plain `CSV` stream. * Keep the same logic with the mainland's one(without `SHA1` validation). * If someone needs this feature built-in, contrubiting is welcome. * * @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/ch/fusion_wallet_ch/QuickPay/chapter8_5.shtml * @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/en/fusion_wallet/QuickPay/chapter8_5.shtml * @see https://pay.weixin.qq.com/wiki/doc/api_external/ch/apis/chapter3_1_6.shtml * @see https://pay.weixin.qq.com/wiki/doc/api_external/en/apis/chapter3_1_6.shtml * * @param ResponseInterface $response - The response instance * * @return string - The JSON string */ protected static function digestBody(ResponseInterface $response): string { return sprintf('{"sha1":"%s"}', $response->getHeader(WechatpayStatementSha1)[0]); } /** * APIv3's verifier middleware stack * * @param array $certs The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair * @return callable(callable(RequestInterface,array)) */ public static function verifier(array &$certs): callable { $assert = static::assertSuccessfulResponse($certs); return static function (callable $handler) use ($assert): callable { return static function (RequestInterface $request, array $options = []) use ($assert, $handler): PromiseInterface { return $handler($request, $options)->then(static function(ResponseInterface $response) use ($assert, $request): ResponseInterface { return $assert($response, $request); }); }; }; } /** * Create an APIv3's client * * Mandatory \$config array paramters * - mchid: string - The merchant ID * - serial: string - The serial number of the merchant certificate * - privateKey: \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string - The merchant private key. * - certs: array{string, \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string} - The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair * * @param array $config - The configuration * @throws \WeChatPay\Exception\InvalidArgumentException */ public static function jsonBased(array $config = []): Client { if (!( isset($config['mchid']) && is_string($config['mchid']) )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_MCHID_IS_MANDATORY); } if (!( isset($config['serial']) && is_string($config['serial']) )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_SERIAL_IS_MANDATORY); } if (!( isset($config['privateKey']) && (is_string($config['privateKey']) || is_resource($config['privateKey']) || is_object($config['privateKey'])) )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_PRIVATEKEY_IS_MANDATORY); } if (!( isset($config['certs']) && is_array($config['certs']) && count($config['certs']) )) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_CERTS_IS_MANDATORY); } if (array_key_exists($config['serial'], $config['certs'])) { throw new Exception\InvalidArgumentException(sprintf( Exception\ERR_INIT_CERTS_EXCLUDE_MCHSERIAL, implode(',', array_keys($config['certs'])), $config['serial'] )); } /** @var HandlerStack $stack */ $stack = isset($config['handler']) && ($config['handler'] instanceof HandlerStack) ? (clone $config['handler']) : HandlerStack::create(); $stack->before('prepare_body', Middleware::mapRequest(static::signer((string)$config['mchid'], $config['serial'], $config['privateKey'])), 'signer'); $stack->before('http_errors', static::verifier($config['certs']), 'verifier'); $config['handler'] = $stack; unset($config['mchid'], $config['serial'], $config['privateKey'], $config['certs'], $config['secret'], $config['merchant']); return new Client(static::withDefaults($config)); } }