store = $store; } public function connected(): bool { return $this->store->connection_id() !== '' && $this->store->seed() !== ''; } public function host(): string { return $this->store->host(); } /** Unix time corrected for the last observed skew against miPlata. */ public function now(): int { return time() + (int) get_option( self::OPT_SKEW, 0 ); } /** * @param string $method GET, POST or DELETE. * @param string $path `/api/external/payments/...` or `/api/connect/...`. * @param array|null $body JSON body. * @param array $query Query parameters. * @param array $extra_headers For example Idempotency-Key. * @return array{ok:bool, status:int, body:array, raw:string, headers:array} */ public function call( string $method, string $path, ?array $body = null, array $query = array(), array $extra_headers = array(), bool $retry_skew = true ): array { $host = $this->host(); if ( ! MiPlata_Protocol::valid_host( $host ) ) { return $this->fail( 0, 'host_invalid', __( 'Host de miPlata no válido.', 'miplata' ) ); } if ( ! $this->connected() ) { return $this->fail( 0, 'not_connected', __( 'La tienda no está conectada a miPlata.', 'miplata' ) ); } $raw_body = null === $body ? '' : wp_json_encode( $body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $qs = $query ? http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ) : ''; $headers = MiPlata_Protocol::sign_request( $this->store->connection_id(), $this->store->seed(), $method, $host, $path, $qs, $raw_body, $this->now(), MIPLATA_VERSION ); $headers['Accept'] = 'application/json'; $headers['User-Agent'] = 'miPlata-WooCommerce/' . MIPLATA_VERSION . ' (+' . home_url( '/' ) . ')'; if ( null !== $body ) { $headers['Content-Type'] = 'application/json; charset=utf-8'; } foreach ( $extra_headers as $k => $v ) { $headers[ $k ] = $v; } $url = 'https://' . $host . $path . ( '' !== $qs ? '?' . $qs : '' ); $resp = wp_remote_request( $url, array( 'method' => $method, 'headers' => $headers, 'body' => null === $body ? null : $raw_body, 'timeout' => self::TIMEOUT, 'redirection' => 0, 'sslverify' => true, 'httpversion' => '1.1', ) ); if ( is_wp_error( $resp ) ) { MiPlata_Log::warn( 'api_unreachable', array( 'path' => $path, 'error' => $resp->get_error_message() ) ); return $this->fail( 0, 'unreachable', $resp->get_error_message() ); } $status = (int) wp_remote_retrieve_response_code( $resp ); $raw = (string) wp_remote_retrieve_body( $resp ); $decoded = json_decode( $raw, true ); $decoded = is_array( $decoded ) ? $decoded : array(); $server = wp_remote_retrieve_header( $resp, 'x-mp-server-time' ); if ( is_string( $server ) && preg_match( '/^\d{9,11}$/', $server ) ) { $skew = (int) $server - time(); if ( abs( $skew ) > 5 ) { update_option( self::OPT_SKEW, $skew, false ); } elseif ( (int) get_option( self::OPT_SKEW, 0 ) !== 0 ) { update_option( self::OPT_SKEW, 0, false ); } } if ( 401 === $status && 'ts_skew' === ( $decoded['code'] ?? '' ) && $retry_skew ) { return $this->call( $method, $path, $body, $query, $extra_headers, false ); } if ( 426 === $status ) { update_option( 'miplata_plugin_outdated', (string) ( $decoded['min_version'] ?? '1' ), false ); } if ( 401 === $status && in_array( $decoded['code'] ?? '', array( 'connection_unknown', 'signature_invalid' ), true ) ) { MiPlata_Log::warn( 'connection_rejected', array( 'code' => $decoded['code'] ) ); $this->store->note_rejection( (string) $decoded['code'] ); } $ok = $status >= 200 && $status < 300; if ( ! $ok ) { MiPlata_Log::warn( 'api_error', array( 'path' => $path, 'status' => $status, 'code' => $decoded['code'] ?? '', 'message' => $decoded['message'] ?? '' ) ); } return array( 'ok' => $ok, 'status' => $status, 'body' => $decoded, 'raw' => $raw, 'headers' => array( 'retry-after' => (string) wp_remote_retrieve_header( $resp, 'retry-after' ) ), ); } private function fail( int $status, string $code, string $message ): array { return array( 'ok' => false, 'status' => $status, 'body' => array( 'code' => $code, 'message' => $message ), 'raw' => '', 'headers' => array() ); } /* ─────────────── typed helpers ─────────────── */ public function capabilities( bool $fresh = false ): ?array { if ( ! $fresh ) { $cached = get_transient( self::CAPS_KEY ); if ( is_array( $cached ) ) { return $cached; } } $r = $this->call( 'GET', '/api/external/payments/capabilities' ); if ( ! $r['ok'] ) { return null; } set_transient( self::CAPS_KEY, $r['body'], self::CAPS_TTL ); if ( ! empty( $r['body']['webhook_public_key'] ) ) { $this->store->set_webhook_public_key( (string) $r['body']['webhook_public_key'] ); } return $r['body']; } public function validate_buyer( array $buyer ): array { return $this->call( 'POST', '/api/external/payments/buyer/validate', array( 'buyer' => $buyer ) ); } public function create_checkout( array $spec, string $idempotency_key ): array { return $this->call( 'POST', '/api/external/payments/checkouts', $spec, array(), array( 'Idempotency-Key' => $idempotency_key ) ); } public function checkout( string $id ): array { return $this->call( 'GET', '/api/external/payments/checkouts/' . rawurlencode( $id ) ); } public function checkout_by_reference( string $ref ): array { return $this->call( 'GET', '/api/external/payments/checkouts', null, array( 'order_reference' => $ref ) ); } public function cancel( string $id ): array { return $this->call( 'POST', '/api/external/payments/checkouts/' . rawurlencode( $id ) . '/cancel', array() ); } public function credit_note( string $id, array $body ): array { return $this->call( 'POST', '/api/external/payments/checkouts/' . rawurlencode( $id ) . '/credit-note', $body ); } /** @return array{ok:bool, bytes:string, status:int} */ public function document( string $id, string $kind ): array { $r = $this->call( 'GET', '/api/external/payments/checkouts/' . rawurlencode( $id ) . '/documents/' . $kind ); return array( 'ok' => $r['ok'], 'bytes' => $r['ok'] ? $r['raw'] : '', 'status' => $r['status'], 'body' => $r['body'] ); } public function document_tokens( string $id, array $kinds = array( 'pdf', 'zip' ) ): array { return $this->call( 'POST', '/api/external/payments/checkouts/' . rawurlencode( $id ) . '/documents', array( 'kinds' => $kinds ) ); } public function health( array $report ): array { return $this->call( 'POST', '/api/connect/health', $report ); } public function delivery_failed( string $checkout_id, string $reason ): array { return $this->call( 'POST', '/api/connect/delivery-failed', array( 'checkout_id' => $checkout_id, 'reason' => $reason ) ); } public function rotate( string $new_public_key, string $proof ): array { return $this->call( 'POST', '/api/connect/rotate', array( 'public_key' => $new_public_key, 'proof' => $proof ) ); } public function revoke_self(): array { return $this->call( 'DELETE', '/api/connect/self' ); } /* ─────────────── unsigned: pairing ─────────────── */ /** * `POST /api/connect/redeem` — the only unsigned call. The seed must * already be published on `/.well-known/miplata-connect.json`. */ public static function redeem( string $host, array $body ): array { if ( ! MiPlata_Protocol::valid_host( $host ) ) { return array( 'ok' => false, 'status' => 0, 'body' => array( 'code' => 'host_invalid', 'message' => 'host' ) ); } $resp = wp_remote_post( 'https://' . $host . '/api/connect/redeem', array( 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json', 'User-Agent' => 'miPlata-WooCommerce/' . MIPLATA_VERSION ), 'body' => wp_json_encode( $body ), 'timeout' => 20, 'redirection' => 0, 'sslverify' => true, ) ); if ( is_wp_error( $resp ) ) { return array( 'ok' => false, 'status' => 0, 'body' => array( 'code' => 'unreachable', 'message' => $resp->get_error_message() ) ); } $status = (int) wp_remote_retrieve_response_code( $resp ); $decoded = json_decode( (string) wp_remote_retrieve_body( $resp ), true ); return array( 'ok' => 201 === $status, 'status' => $status, 'body' => is_array( $decoded ) ? $decoded : array() ); } }