├── .github └── dependabot.yml ├── .gitignore ├── .rspec ├── .semver ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── fnsapi.gemspec ├── lib ├── fnsapi.rb └── fnsapi │ ├── auth_service.rb │ ├── base_service.rb │ ├── configuration.rb │ ├── get_message_service.rb │ ├── kkt_concern.rb │ ├── kkt_service.rb │ ├── ticket.rb │ ├── tmp_storage.rb │ └── version.rb └── spec ├── auth_service_spec.rb ├── base_service_spec.rb ├── configuration_spec.rb ├── fnsapi_spec.rb ├── get_message_service_spec.rb ├── kkt_service_spec.rb ├── spec_helper.rb ├── ticket_spec.rb └── tmp_storage_spec.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "02:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: nokogiri 11 | versions: 12 | - 1.11.1 13 | - 1.11.2 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.semver: -------------------------------------------------------------------------------- 1 | --- 2 | :major: 1 3 | :minor: 1 4 | :patch: 0 5 | :special: '' 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.6.3 7 | before_install: gem install bundler -v 2.1.4 8 | services: 9 | - redis-server 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.2] 4 | - Gems and security updates. 5 | 6 | ## [1.1.0] 7 | - Add log_enabled and logger configuration 8 | 9 | ## [1.0.0] 10 | - Working version 11 | 12 | ## [0.1.0] 13 | - Project initialization 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in fnsapi.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | fnsapi (1.1.2) 5 | savon 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | addressable (2.7.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | akami (1.3.1) 13 | gyoku (>= 0.4.0) 14 | nokogiri 15 | builder (3.2.4) 16 | diff-lcs (1.4.4) 17 | gyoku (1.3.1) 18 | builder (>= 2.1.2) 19 | httpi (2.4.5) 20 | rack 21 | socksify 22 | mini_portile2 (2.4.0) 23 | nokogiri (1.10.10) 24 | mini_portile2 (~> 2.4.0) 25 | nori (2.6.0) 26 | public_suffix (4.0.6) 27 | rack (2.2.3) 28 | rake (13.0.1) 29 | rspec (3.10.0) 30 | rspec-core (~> 3.10.0) 31 | rspec-expectations (~> 3.10.0) 32 | rspec-mocks (~> 3.10.0) 33 | rspec-core (3.10.0) 34 | rspec-support (~> 3.10.0) 35 | rspec-expectations (3.10.0) 36 | diff-lcs (>= 1.2.0, < 2.0) 37 | rspec-support (~> 3.10.0) 38 | rspec-mocks (3.10.0) 39 | diff-lcs (>= 1.2.0, < 2.0) 40 | rspec-support (~> 3.10.0) 41 | rspec-support (3.10.0) 42 | savon (2.12.1) 43 | akami (~> 1.2) 44 | builder (>= 2.1.2) 45 | gyoku (~> 1.2) 46 | httpi (~> 2.3) 47 | nokogiri (>= 1.8.1) 48 | nori (~> 2.4) 49 | wasabi (~> 3.4) 50 | socksify (1.7.1) 51 | wasabi (3.6.1) 52 | addressable 53 | httpi (~> 2.0) 54 | nokogiri (>= 1.4.2) 55 | 56 | PLATFORMS 57 | ruby 58 | 59 | DEPENDENCIES 60 | bundler 61 | fnsapi! 62 | rake 63 | rspec 64 | 65 | BUNDLED WITH 66 | 2.1.4 67 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Alexey Naumov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fnsapi 2 | 3 | Gem implements API with Federal Tax services of Russia. 4 | 5 | Гем реализуют взаимодействие с официальным апи проверки чеков Федеральной Налоговая службы России. Чтобы получить токен неоходимо подать заявку на сайте ФНС. [Документация для получения токена](https://www.nalog.ru/files/kkt/pdf/%D0%A2%D0%B5%D1%85%D0%BD%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%B8%D0%B5%20%D1%83%D1%81%D0%BB%D0%BE%D0%B2%D0%B8%D1%8F%20%D0%B8%D1%81%D0%BF%D0%BE%D0%BB%D1%8C%D0%B7%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F.pdf). 6 | 7 | 8 | [Недокументированное апи для работы с данными чеков ФНС](https://habr.com/ru/post/358966/) (при большом колиечестве проверок, работает не стабильно). 9 | 10 | ![Build Status](https://api.travis-ci.org/actie/fnsapi.svg?branch=master) 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'fnsapi', github: 'actie/fnsapi' 18 | ``` 19 | 20 | Then: 21 | 22 | $ bundle 23 | 24 | ## Configuration 25 | 26 | You can configure this gem in `config/initializers/fnsapi.rb`: 27 | 28 | ```ruby 29 | Fnsapi.configure do |config| 30 | config.redis_url = ENV.fetch('REDIS_URL') 31 | config.fnsapi_master_key = ENV.fetch('FNS_API_MASTER_KEY') 32 | config.fnsapi_user_token = ENV.fetch('FNS_API_USER_TOKEN') 33 | end 34 | ``` 35 | 36 | The only one parameter, which you must specify is `fnsapi_master_key`. 37 | And if you want to store temporary credentials in redis specify `redis_url`. If you don't credentials will be stored in the tmp file. 38 | 39 | The full parameters list for configuration with default values: 40 | ``` 41 | fns_host = 'https://openapi.nalog.ru' 42 | fns_port = 8090 43 | redis_key = :fnsapi_token 44 | redis_url = nil 45 | tmp_file_name = 'fnsapi_tmp_credentials' 46 | fnsapi_master_key = nil 47 | fnsapi_user_token = nil 48 | get_message_timeout = 60 49 | log_enabled = false 50 | logger = Logger.new($stdout) 51 | ``` 52 | 53 | ### get message timeout 54 | 55 | FNS provides us an asynchronous API. So, we need to make two requests: first to generate the message, and second to receive it. And there is a timeout on a server side. It's possible to download the message only within around the 60 seconds after request. We use the [exponential backoff algorithm](https://en.wikipedia.org/wiki/Exponential_backoff) with 60 seconds timeout. You can specify the different value but if it is too big, you'll just receive the TimeoutException from FNS backend. 56 | 57 | ### log_enabled 58 | 59 | If this option id true, all SAVON logs will be written in logger. 60 | 61 | ### logger 62 | 63 | By default it's a `stdout` stream but if you use this gem with Rails application, logger will be configurated as `Rails.logger` automaticaly. 64 | 65 | ## Usage 66 | 67 | There are two methods: 68 | ```ruby 69 | # Is check data correct? 70 | Fnsapi.check_data(ticket, user_id) # true / false 71 | # Give me full information about check: products, INN etc. 72 | Fnsapi.get_data(ticket, user_id) 73 | ``` 74 | 75 | `ticket` could be both an object which implements methods or a hash with the same keys: 76 | 77 | ``` 78 | fn - Fiscal number 79 | fd - Fiscal document id 80 | pfd - Fiscal signature 81 | purchase_date - Ticket purchase date with time (we have tested for Moscow timezone but this point is not documented, and FNS API don't acept time with timezone, so I don't sure what timezone can you use.) 82 | amount_cents - Ticket amount in cents (Integer) 83 | ``` 84 | 85 | `user_id` - is an optional parameter. You can send the ID for user in you system if you want to specify which person do this request. In other way it has a default value `'default_user'`. 86 | 87 | ## Contributing 88 | 89 | Bug reports and pull requests are welcome on GitHub at https://github.com/actie/fnsapi. 90 | 91 | ## License 92 | 93 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 94 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "fnsapi" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /fnsapi.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require 'fnsapi/version' 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = 'fnsapi' 10 | spec.version = Fnsapi::VERSION 11 | spec.authors = ['Fedor Koshel'] 12 | spec.email = ['alexsnaumov@gmail.com'] 13 | 14 | spec.summary = %(Ruby implementation for Russian FNS api) 15 | spec.description = %(If you got approved for wirking with Russian FNS API, this gem will help you.) 16 | spec.homepage = 'https://github.com/actie/fnsapi' 17 | spec.license = 'MIT' 18 | 19 | if spec.respond_to?(:metadata) 20 | spec.metadata['homepage_uri'] = spec.homepage 21 | spec.metadata['source_code_uri'] = 'https://github.com/actie/fnsapi' 22 | spec.metadata['changelog_uri'] = 'https://github.com/actie/fnsapi/CHANGELOG.md' 23 | else 24 | raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.' 25 | end 26 | 27 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) } 28 | spec.require_paths = ['lib'] 29 | spec.add_runtime_dependency 'savon' 30 | 31 | spec.add_development_dependency 'bundler' 32 | spec.add_development_dependency 'rake' 33 | spec.add_development_dependency 'rspec' 34 | end 35 | -------------------------------------------------------------------------------- /lib/fnsapi.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'fnsapi/ticket' 5 | require 'fnsapi/version' 6 | require 'fnsapi/configuration' 7 | require 'fnsapi/tmp_storage' 8 | require 'fnsapi/base_service' 9 | require 'fnsapi/auth_service' 10 | require 'fnsapi/kkt_concern' 11 | require 'fnsapi/get_message_service' 12 | require 'fnsapi/kkt_service' 13 | 14 | module Fnsapi 15 | class << self 16 | def configuration 17 | @configuration ||= Configuration.new 18 | end 19 | 20 | def configure 21 | yield(configuration) 22 | end 23 | 24 | def check_data(ticket, user_id = 'default_user') 25 | Fnsapi::KktService.new.check_data(ticket, user_id) 26 | end 27 | 28 | def get_data(ticket, user_id = 'default_user') 29 | Fnsapi::KktService.new.get_data(ticket, user_id) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/fnsapi/auth_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | class AuthService < BaseService 5 | def reset_credentials 6 | result = client.call(:get_message, message: message_hash) 7 | message = result.body.dig(:get_message_response, :message) 8 | 9 | raise RequestError, message[:fault][:message] if message[:fault] 10 | 11 | token = message.dig(:auth_response, :result, :token) 12 | expired_at_raw = message.dig(:auth_response, :result, :expire_time) 13 | expired_at = expired_at_raw.is_a?(DateTime) ? expired_at_raw.to_time : Time.parse(expired_at_raw) 14 | 15 | return if token.blank? 16 | 17 | put_token!(token, expired_at) 18 | token 19 | end 20 | 21 | private 22 | 23 | def namespaces 24 | super.merge( 25 | 'xmlns:tns' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/AuthService/types/1.0', 26 | 'targetNamespace' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/AuthService/types/1.0' 27 | ) 28 | end 29 | 30 | def uri 31 | '/open-api/AuthService/0.1?wsdl' 32 | end 33 | 34 | def message_hash 35 | { 36 | 'Message' => { 37 | 'tns:AuthRequest' => { 38 | 'tns:AuthAppInfo' => { 39 | 'tns:MasterToken' => Fnsapi.configuration.fnsapi_master_key 40 | } 41 | } 42 | } 43 | } 44 | end 45 | 46 | def put_token!(token, expired_at) 47 | if redis 48 | redis.set(Fnsapi.configuration.redis_key, token) 49 | redis.expireat(Fnsapi.configuration.redis_key, expired_at.to_i) 50 | else 51 | tmp_storage.write_token(token, expired_at) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/fnsapi/base_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'savon' 4 | 5 | module Fnsapi 6 | class RequestError < StandardError; end 7 | class NotImplementedError < StandardError; end 8 | 9 | class BaseService 10 | def client(additional_params = {}) 11 | Savon.client(client_params(additional_params)) 12 | end 13 | 14 | private 15 | 16 | def namespaces 17 | { 'xmlns:xs' => 'http://www.w3.org/2001/XMLSchema' } 18 | end 19 | 20 | def client_params(additional_params = {}) 21 | { 22 | wsdl: "#{fns_url}#{uri}", 23 | namespaces: namespaces, 24 | env_namespace: :soap, 25 | log: Fnsapi.configuration.log_enabled, 26 | logger: Fnsapi.configuration.logger 27 | }.merge(additional_params) 28 | end 29 | 30 | def uri 31 | raise NotImplementedError 32 | end 33 | 34 | def redis 35 | return false unless Fnsapi.configuration.redis_url 36 | 37 | @redis ||= Redis.new(url: Fnsapi.configuration.redis_url) 38 | end 39 | 40 | def tmp_storage 41 | @tmp_storage ||= TmpStorage.new 42 | end 43 | 44 | def token 45 | if redis 46 | redis.get(Fnsapi.configuration.redis_key) 47 | else 48 | tmp_storage.token 49 | end 50 | end 51 | 52 | def fns_url 53 | "#{Fnsapi.configuration.fns_host}:#{Fnsapi.configuration.fns_port}" 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/fnsapi/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | class InvalidConfigurationError < StandardError; end 5 | 6 | class Configuration 7 | attr_accessor :fns_host, 8 | :fns_port, 9 | :redis_key, 10 | :redis_url, 11 | :tmp_file_name, 12 | :get_message_timeout, 13 | :log_enabled, 14 | :logger 15 | 16 | attr_writer :fnsapi_user_token, 17 | :fnsapi_master_key 18 | 19 | def initialize 20 | @fns_host = 'https://openapi.nalog.ru' 21 | @fns_port = 8090 22 | @redis_key = :fnsapi_token 23 | @redis_url = nil 24 | @tmp_file_name = 'fnsapi_tmp_credentials' 25 | @fnsapi_master_key = nil 26 | @fnsapi_user_token = nil 27 | @get_message_timeout = 60 28 | @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout) 29 | @log_enabled = false 30 | end 31 | 32 | def fnsapi_user_token 33 | return @fnsapi_user_token if @fnsapi_user_token 34 | 35 | raise InvalidConfigurationError, 'fnsapi_user_token must be specified' 36 | end 37 | 38 | def fnsapi_master_key 39 | return @fnsapi_master_key if @fnsapi_master_key 40 | 41 | raise InvalidConfigurationError, 'fnsapi_master_key must be specified' 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/fnsapi/get_message_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | class GetMessageService < BaseService 5 | include KktConcern 6 | 7 | def call(message_id, user_id) 8 | result = client(auth_params(user_id)).call(:get_message, message: message_hash(message_id)) 9 | result.body.dig(:get_message_response) 10 | end 11 | 12 | private 13 | 14 | def namespaces 15 | super.merge( 16 | 'xmlns:tns' => 'urn://x-artefacts-gnivc-ru/inplat/servin/OpenApiAsyncMessageConsumerService/types/1.0' 17 | ) 18 | end 19 | 20 | def message_hash(message_id) 21 | { 'tns:MessageId' => message_id } 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/fnsapi/kkt_concern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | module KktConcern 5 | def auth_params(user_id) 6 | refresh_credentials! unless token 7 | 8 | { headers: { 'FNS-OpenApi-Token' => token, 'FNS-OpenApi-UserToken' => Base64.strict_encode64(user_id.to_s) } } 9 | end 10 | 11 | def uri 12 | '/open-api/ais3/KktService/0.1?wsdl' 13 | end 14 | 15 | def refresh_credentials! 16 | AuthService.new.reset_credentials 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/fnsapi/kkt_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | class KktService < BaseService 5 | include KktConcern 6 | 7 | def check_data(object, user_id = 'default_user') 8 | ticket = Ticket.new(object) 9 | result = client(auth_params(user_id)).call(:send_message, message: check_ticket_hash(ticket)) 10 | message_id = result.body.dig(:send_message_response, :message_id) 11 | 12 | message = parse_message(message_id, user_id) 13 | return unless message 14 | 15 | code = message.dig(:check_ticket_response, :result, :code) 16 | code == '200' 17 | end 18 | 19 | def get_data(object, user_id = 'default_user') 20 | ticket = Ticket.new(object) 21 | result = client(auth_params(user_id)).call(:send_message, message: get_ticket_hash(ticket)) 22 | message_id = result.body.dig(:send_message_response, :message_id) 23 | 24 | message = parse_message(message_id, user_id) 25 | return unless message 26 | 27 | code = message.dig(:get_ticket_response, :result, :code) 28 | return code if code != '200' 29 | 30 | JSON.parse(message.dig(:get_ticket_response, :result, :ticket)) 31 | end 32 | 33 | private 34 | 35 | def parse_message(message_id, user_id) 36 | wait_time = 0 37 | i = 0 38 | 39 | while true do 40 | response = GetMessageService.new.call(message_id, user_id) 41 | return response[:message] if response[:processing_status] == 'COMPLETED' 42 | 43 | timeout = (2**i - 1)/2 44 | wait_time += timeout 45 | i += 1 46 | 47 | break if wait_time > Fnsapi.configuration.get_message_timeout 48 | 49 | sleep(timeout) 50 | end 51 | 52 | raise RequestError, 'Timeout reached' 53 | end 54 | 55 | def namespaces 56 | super.merge( 57 | 'xmlns:tns' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/KktTicketService/types/1.0', 58 | 'targetNamespace' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/KktTicketService/types/1.0' 59 | ) 60 | end 61 | 62 | def check_ticket_hash(ticket) 63 | { 64 | 'Message' => { 65 | 'tns:CheckTicketRequest' => { 66 | 'tns:CheckTicketInfo' => ticket_hash(ticket) 67 | } 68 | } 69 | } 70 | end 71 | 72 | def get_ticket_hash(ticket) 73 | { 74 | 'Message' => { 75 | 'tns:GetTicketRequest' => { 76 | 'tns:GetTicketInfo' => ticket_hash(ticket) 77 | } 78 | } 79 | } 80 | end 81 | 82 | def ticket_hash(ticket) 83 | { 84 | 'tns:Fn' => ticket.fn, 85 | 'tns:FiscalDocumentId' => ticket.fd, 86 | 'tns:FiscalSign' => ticket.pfd, 87 | 'tns:Date' => ticket.purchase_date.strftime('%FT%T'), 88 | 'tns:Sum' => ticket.amount_cents, 89 | 'tns:TypeOperation' => 1 90 | } 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/fnsapi/ticket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | class FieldNotSpecifiedError < StandardError; end 5 | 6 | class Ticket 7 | attr_reader :fn, :fd, :pfd, :purchase_date, :amount_cents 8 | 9 | def initialize(object) 10 | %i[fn fd pfd amount_cents].each do |field_name| 11 | instance_variable_set("@#{field_name}", validated_field_value(object, field_name)) 12 | end 13 | 14 | @purchase_date = validated_field_value(object, :purchase_date) 15 | @purchase_date = DateTime.parse(@purchase_date) if @purchase_date.is_a?(String) 16 | true 17 | end 18 | 19 | 20 | private 21 | 22 | def validated_field_value(object, field) 23 | value = if object.is_a?(Hash) 24 | object[field] || object[field.to_s] 25 | else 26 | object.public_send(field) 27 | end 28 | 29 | raise FieldNotSpecifiedError, "#{field} should be specified" if value.blank? 30 | 31 | value 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/fnsapi/tmp_storage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | class TmpStorage 5 | def initialize 6 | @file = File.open(file_path, 'a+') 7 | end 8 | 9 | def write_token(token, expire_at) 10 | @file.truncate(0) 11 | @file.write({ token: token, expire_at: expire_at }.to_json) 12 | @file.rewind 13 | end 14 | 15 | def token 16 | data = JSON.parse(@file.read) 17 | expired_at = Time.parse(data['expire_at']) 18 | 19 | if expired_at < Time.now 20 | @file.truncate(0) 21 | return 22 | end 23 | 24 | data['token'] 25 | rescue JSON::ParserError 26 | @file.truncate(0) 27 | nil 28 | end 29 | 30 | private 31 | 32 | def file_path 33 | if defined?(Rails) 34 | Rails.root.join('tmp', Fnsapi.configuration.tmp_file_name) 35 | else 36 | 'tmp/' + Fnsapi.configuration.tmp_file_name 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/fnsapi/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fnsapi 4 | VERSION = '1.1.2' 5 | end 6 | -------------------------------------------------------------------------------- /spec/auth_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi::AuthService do 4 | before do 5 | allow_any_instance_of(Fnsapi::Configuration).to receive(:fnsapi_master_key).and_return('test_key') 6 | allow(Fnsapi::TmpStorage).to receive(:new).and_return(StubbedTmpStorage.new) 7 | end 8 | 9 | let(:instance) { described_class.new } 10 | let(:correct_namespaces) do 11 | { 12 | 'xmlns:xs' => 'http://www.w3.org/2001/XMLSchema', 13 | 'xmlns:tns' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/AuthService/types/1.0', 14 | 'targetNamespace' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/AuthService/types/1.0' 15 | } 16 | end 17 | let(:correct_message_hash) do 18 | { 19 | 'Message' => { 20 | 'tns:AuthRequest' => { 21 | 'tns:AuthAppInfo' => { 22 | 'tns:MasterToken' => 'test_key' 23 | } 24 | } 25 | } 26 | } 27 | end 28 | let(:expired_at) { Time.now } 29 | let(:stubbed_response) do 30 | OpenStruct.new( 31 | body: { 32 | get_message_response: { 33 | message: { 34 | auth_response: { 35 | result: { 36 | token: 'token', 37 | expire_time: expired_at.to_s 38 | } 39 | } 40 | } 41 | } 42 | } 43 | ) 44 | end 45 | 46 | describe '#client' do 47 | let(:client) { instance.client } 48 | let(:options) { client.globals.instance_variable_get(:@options) } 49 | 50 | it 'initializes Savon client instance' do 51 | expect(client).to be_kind_of(Savon::Client) 52 | end 53 | 54 | it 'contains correct wsdl' do 55 | expect(options[:wsdl]).to eq('https://openapi.nalog.ru:8090/open-api/AuthService/0.1?wsdl') 56 | end 57 | 58 | it 'contains correct namespaces' do 59 | expect(options[:namespaces]).to eq(correct_namespaces) 60 | end 61 | end 62 | 63 | describe '#reset_credentials' do 64 | before do 65 | allow_any_instance_of(Savon::Client).to receive(:call) { stubbed_response } 66 | end 67 | 68 | subject(:reset_credentials) { instance.reset_credentials } 69 | 70 | it 'calls :get_message with correct parameters' do 71 | expect_any_instance_of(Savon::Client).to( 72 | receive(:call).with(:get_message, message: correct_message_hash).and_return(stubbed_response) 73 | ) 74 | reset_credentials 75 | end 76 | 77 | it 'returns token' do 78 | expect(reset_credentials).to eq('token') 79 | end 80 | 81 | context 'when there is fault message' do 82 | before do 83 | allow_any_instance_of(Savon::Client).to receive(:call) do 84 | OpenStruct.new( 85 | body: { 86 | get_message_response: { 87 | message: { 88 | fault: { 89 | message: 'fault_message' 90 | } 91 | } 92 | } 93 | } 94 | ) 95 | end 96 | end 97 | 98 | it 'raises RequestError with this message' do 99 | expect { reset_credentials }.to raise_exception(Fnsapi::RequestError, 'fault_message') 100 | end 101 | end 102 | 103 | context 'when redis_url is defined' do 104 | before do 105 | allow_any_instance_of(Fnsapi::Configuration).to receive(:redis_url).and_return('redis_url') 106 | end 107 | 108 | it 'initializes redis object' do 109 | expect(Redis).to receive(:new).with(url: 'redis_url') 110 | reset_credentials 111 | end 112 | 113 | it 'saves token to redis' do 114 | expect_any_instance_of(Redis).to receive(:set).with(:fnsapi_token, 'token') 115 | reset_credentials 116 | end 117 | 118 | it 'sets expired_at in redis' do 119 | expect_any_instance_of(Redis).to receive(:expireat).with(:fnsapi_token, expired_at.to_i) 120 | reset_credentials 121 | end 122 | end 123 | 124 | context 'when redis_url is not defined' do 125 | it 'initializes TmpStorage object' do 126 | expect(Fnsapi::TmpStorage).to receive(:new).and_return(StubbedTmpStorage.new) 127 | reset_credentials 128 | end 129 | 130 | it 'saves token to tmp_storage' do 131 | expect_any_instance_of(StubbedTmpStorage).to receive(:write_token).with('token', Time.at(expired_at.to_i)) 132 | reset_credentials 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/base_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi::BaseService do 4 | before do 5 | allow_any_instance_of(Fnsapi::Configuration).to receive(:fnsapi_master_key).and_return('test_key') 6 | allow_any_instance_of(Fnsapi::Configuration).to receive(:log_enabled).and_return(true) 7 | allow(Fnsapi::TmpStorage).to receive(:new).and_return(StubbedTmpStorage.new) 8 | allow_any_instance_of(Fnsapi::BaseService).to receive(:uri) { '/base_uri' } 9 | end 10 | 11 | let(:instance) { described_class.new } 12 | let(:correct_namespaces) do 13 | { 'xmlns:xs' => 'http://www.w3.org/2001/XMLSchema' } 14 | end 15 | 16 | describe '#client' do 17 | let(:client) { instance.client } 18 | let(:options) { client.globals.instance_variable_get(:@options) } 19 | 20 | it 'initializes Savon client instance' do 21 | expect(client).to be_kind_of(Savon::Client) 22 | end 23 | 24 | it 'contains correct wsdl' do 25 | expect(options[:wsdl]).to eq('https://openapi.nalog.ru:8090/base_uri') 26 | end 27 | 28 | it 'contains correct namespaces' do 29 | expect(options[:namespaces]).to eq(correct_namespaces) 30 | end 31 | 32 | it 'contains correct env_namespace' do 33 | expect(options[:env_namespace]).to eq(:soap) 34 | end 35 | 36 | it 'contains log configuration' do 37 | expect(options[:log]).to eq(true) 38 | end 39 | 40 | it 'contains logger configuration' do 41 | expect(options[:logger]).to be_a(Logger) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi::Configuration do 4 | let(:config) { described_class.new } 5 | 6 | describe 'readers' do 7 | it 'returns correct value for fns_host' do 8 | expect(config.fns_host).to eq('https://openapi.nalog.ru') 9 | end 10 | 11 | it 'returns correct value for fns_port' do 12 | expect(config.fns_port).to eq(8090) 13 | end 14 | 15 | it 'returns correct value for redis_key' do 16 | expect(config.redis_key).to eq(:fnsapi_token) 17 | end 18 | 19 | it 'returns correct value for redis_url' do 20 | expect(config.redis_url).to eq(nil) 21 | end 22 | 23 | it 'returns correct value for tmp_file_name' do 24 | expect(config.tmp_file_name).to eq('fnsapi_tmp_credentials') 25 | end 26 | 27 | it 'returns correct value for get_message_timeout' do 28 | expect(config.get_message_timeout).to eq(60) 29 | end 30 | 31 | it 'returns correct value for log_enabled' do 32 | expect(config.log_enabled).to eq(false) 33 | end 34 | 35 | it 'returns correct value for logger' do 36 | expect(config.logger).to be_a(Logger) 37 | end 38 | 39 | context 'when used in Rails app' do 40 | before do 41 | class Rails; end 42 | 43 | allow(Rails).to receive(:logger).and_return(Logger.new($stdout)) 44 | end 45 | 46 | after { Object.send(:remove_const, :Rails) } 47 | 48 | it 'uses Rails.logger' do 49 | expect(Rails).to receive(:logger) 50 | expect(config.logger).to be_a(Logger) 51 | end 52 | end 53 | end 54 | 55 | describe 'writers' do 56 | it 'changes value for fns_host' do 57 | expect { config.fns_host = 'test' }.to change { config.fns_host }.from('https://openapi.nalog.ru').to('test') 58 | end 59 | 60 | it 'changes value for fns_port' do 61 | expect { config.fns_port = 1234 }.to change { config.fns_port }.from(8090).to(1234) 62 | end 63 | 64 | it 'changes value for redis_key' do 65 | expect { config.redis_key = 'test' }.to change { config.redis_key }.from(:fnsapi_token).to('test') 66 | end 67 | 68 | it 'changes value for redis_url' do 69 | expect { config.redis_url = 'test' }.to change { config.redis_url }.from(nil).to('test') 70 | end 71 | 72 | it 'changes value for tmp_file_name' do 73 | expect { config.tmp_file_name = 'test' }.to( 74 | change { config.tmp_file_name }.from('fnsapi_tmp_credentials').to('test') 75 | ) 76 | end 77 | 78 | it 'changes value for log_enabled' do 79 | expect { config.log_enabled = true }.to( 80 | change { config.log_enabled }.from(false).to(true) 81 | ) 82 | end 83 | 84 | it 'changes value for logger' do 85 | expect { config.logger = Logger.new($stdout) }.to(change { config.logger }) 86 | end 87 | 88 | it 'changes value for get_message_timeout' do 89 | expect { config.get_message_timeout = 10 }.to( 90 | change { config.get_message_timeout }.from(60).to(10) 91 | ) 92 | end 93 | 94 | it 'changes value for fnsapi_master_key' do 95 | config.fnsapi_master_key = 'test' 96 | expect(config.fnsapi_master_key).to eq('test') 97 | end 98 | 99 | it 'changes value for fnsapi_user_token' do 100 | config.fnsapi_user_token = 'test' 101 | expect(config.fnsapi_user_token).to eq('test') 102 | end 103 | end 104 | 105 | describe 'required attributes' do 106 | it 'raises exception if fnsapi_master_key is not defined' do 107 | expect { config.fnsapi_master_key }.to raise_error(Fnsapi::InvalidConfigurationError) 108 | end 109 | 110 | it 'raises exception if fnsapi_user_token is not defined' do 111 | expect { config.fnsapi_user_token }.to raise_error(Fnsapi::InvalidConfigurationError) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/fnsapi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi do 4 | it 'has a version number' do 5 | expect(described_class::VERSION).not_to be nil 6 | end 7 | 8 | describe '#configuration' do 9 | it 'returns and instance of Configuration class' do 10 | expect(described_class.configuration).to be_kind_of(Fnsapi::Configuration) 11 | end 12 | end 13 | 14 | describe '#configure' do 15 | subject(:configure) do 16 | described_class.configure { |config| config.fns_port = 1234 } 17 | end 18 | after { described_class.configure { |config| config.fns_port = 8090 } } 19 | 20 | it 'changes configuration parameters' do 21 | expect { configure }.to change { described_class.configuration.fns_port }.from(8090).to(1234) 22 | end 23 | end 24 | 25 | describe '#check_data' do 26 | before do 27 | allow_any_instance_of(Fnsapi::Configuration).to receive(:fnsapi_master_key).and_return('test_key') 28 | end 29 | 30 | it 'calls Fnsapi::KktService instance method check_data' do 31 | expect_any_instance_of(Fnsapi::KktService).to receive(:check_data).with('test', 123) 32 | described_class.check_data('test', 123) 33 | end 34 | end 35 | 36 | describe '#get_data' do 37 | before do 38 | allow_any_instance_of(Fnsapi::Configuration).to receive(:fnsapi_master_key).and_return('test_key') 39 | end 40 | 41 | it 'calls Fnsapi::KktService instance method get_data' do 42 | expect_any_instance_of(Fnsapi::KktService).to receive(:get_data).with('test', 123) 43 | described_class.get_data('test', 123) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/get_message_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi::GetMessageService do 4 | before do 5 | allow_any_instance_of(Fnsapi::Configuration).to receive(:fnsapi_master_key).and_return('test_key') 6 | allow(Fnsapi::TmpStorage).to receive(:new).and_return(StubbedTmpStorage.new) 7 | allow_any_instance_of(Fnsapi::AuthService).to receive(:reset_credentials).and_return('token') 8 | end 9 | 10 | let(:instance) { described_class.new } 11 | let(:correct_namespaces) do 12 | { 13 | 'xmlns:xs' => 'http://www.w3.org/2001/XMLSchema', 14 | 'xmlns:tns' => 'urn://x-artefacts-gnivc-ru/inplat/servin/OpenApiAsyncMessageConsumerService/types/1.0' 15 | } 16 | end 17 | let(:correct_message_hash) { { 'tns:MessageId' => message_id } } 18 | let(:message_id) { 'test_id' } 19 | let(:stubbed_response) do 20 | OpenStruct.new( 21 | body: { 22 | get_message_response: 'test' 23 | } 24 | ) 25 | end 26 | 27 | describe '#client' do 28 | let(:client) { instance.client } 29 | let(:options) { client.globals.instance_variable_get(:@options) } 30 | 31 | it 'initializes Savon client instance' do 32 | expect(client).to be_kind_of(Savon::Client) 33 | end 34 | 35 | it 'contains correct wsdl' do 36 | expect(options[:wsdl]).to eq('https://openapi.nalog.ru:8090/open-api/ais3/KktService/0.1?wsdl') 37 | end 38 | 39 | it 'contains correct namespaces' do 40 | expect(options[:namespaces]).to eq(correct_namespaces) 41 | end 42 | end 43 | 44 | describe '#call' do 45 | before do 46 | allow_any_instance_of(Savon::Client).to receive(:call) { stubbed_response } 47 | end 48 | 49 | subject(:call) { instance.call(message_id, 123) } 50 | 51 | context 'when token is not defined' do 52 | it 'calls reset_credentials from AuthService' do 53 | expect_any_instance_of(Fnsapi::AuthService).to receive(:reset_credentials) { 'some token' } 54 | call 55 | end 56 | end 57 | 58 | context 'when token is defined' do 59 | before do 60 | allow_any_instance_of(StubbedTmpStorage).to receive(:token) { 'token' } 61 | end 62 | 63 | it 'initializes client with auth_params' do 64 | expect(Savon).to receive(:client).with( 65 | hash_including( 66 | headers: { 67 | 'FNS-OpenApi-Token' => 'token', 68 | 'FNS-OpenApi-UserToken' => Base64.strict_encode64(123.to_s) 69 | } 70 | ) 71 | ).and_call_original 72 | call 73 | end 74 | 75 | it 'calls :get_message with correct parameters' do 76 | expect_any_instance_of(Savon::Client).to( 77 | receive(:call).with(:get_message, message: correct_message_hash).and_return(stubbed_response) 78 | ) 79 | call 80 | end 81 | 82 | it 'returns parsed response' do 83 | expect(call).to eq('test') 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/kkt_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi::KktService do 4 | before do 5 | allow_any_instance_of(Fnsapi::Configuration).to receive(:fnsapi_master_key).and_return('test_key') 6 | allow(Fnsapi::TmpStorage).to receive(:new).and_return(StubbedTmpStorage.new) 7 | allow_any_instance_of(Fnsapi::AuthService).to receive(:reset_credentials).and_return('token') 8 | end 9 | 10 | let(:instance) { described_class.new } 11 | let(:correct_namespaces) do 12 | { 13 | 'xmlns:xs' => 'http://www.w3.org/2001/XMLSchema', 14 | 'xmlns:tns' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/KktTicketService/types/1.0', 15 | 'targetNamespace' => 'urn://x-artefacts-gnivc-ru/ais3/kkt/KktTicketService/types/1.0' 16 | } 17 | end 18 | let(:purchase_date) { DateTime.now } 19 | let(:ticket) do 20 | OpenStruct.new( 21 | fn: '123', 22 | fd: '456', 23 | pfd: '789', 24 | purchase_date: purchase_date, 25 | amount_cents: 100_500 26 | ) 27 | end 28 | let(:ticket_hash) do 29 | { 30 | 'tns:Fn' => ticket.fn, 31 | 'tns:FiscalDocumentId' => ticket.fd, 32 | 'tns:FiscalSign' => ticket.pfd, 33 | 'tns:Date' => ticket.purchase_date.strftime('%FT%T'), 34 | 'tns:Sum' => ticket.amount_cents, 35 | 'tns:TypeOperation' => 1 36 | } 37 | end 38 | 39 | describe '#client' do 40 | let(:client) { instance.client } 41 | let(:options) { client.globals.instance_variable_get(:@options) } 42 | 43 | it 'initializes Savon client instance' do 44 | expect(client).to be_kind_of(Savon::Client) 45 | end 46 | 47 | it 'contains correct namespaces' do 48 | expect(options[:namespaces]).to eq(correct_namespaces) 49 | end 50 | end 51 | 52 | shared_examples 'kkt_service_with_auth_params' do 53 | context 'when token is not defined' do 54 | let(:params) { [ticket] } 55 | before do 56 | allow_any_instance_of(Fnsapi::GetMessageService).to( 57 | receive(:call).and_return(processing_status: 'COMPLETED', message: get_message_result) 58 | ) 59 | end 60 | 61 | it 'calls reset_credentials from AuthService' do 62 | expect_any_instance_of(Fnsapi::AuthService).to receive(:reset_credentials) { 'some token' } 63 | subject 64 | end 65 | end 66 | 67 | context 'when token is defined' do 68 | before do 69 | allow_any_instance_of(StubbedTmpStorage).to receive(:token) { 'token' } 70 | allow_any_instance_of(Fnsapi::GetMessageService).to( 71 | receive(:call).and_return(processing_status: 'COMPLETED', message: get_message_result) 72 | ) 73 | end 74 | 75 | let(:params) { [ticket, 123] } 76 | 77 | it 'calls :get_message with correct parameters' do 78 | expect_any_instance_of(Savon::Client).to( 79 | receive(:call).with(:send_message, message: correct_message_hash).and_return(stubbed_response) 80 | ) 81 | subject 82 | end 83 | 84 | it 'calls GetMessageService with correct params' do 85 | expect_any_instance_of(Fnsapi::GetMessageService).to receive(:call).with('test_id', 123) 86 | subject 87 | end 88 | 89 | context 'when user is not specified' do 90 | let(:params) { [ticket] } 91 | 92 | it 'initializes client with auth_params' do 93 | expect(Savon).to receive(:client).with( 94 | hash_including( 95 | headers: { 96 | 'FNS-OpenApi-Token' => 'token', 97 | 'FNS-OpenApi-UserToken' => Base64.strict_encode64('default_user'.to_s) 98 | } 99 | ) 100 | ).and_call_original 101 | subject 102 | end 103 | end 104 | 105 | context 'when user is specified' do 106 | it 'initializes client with auth_params' do 107 | expect(Savon).to receive(:client).with( 108 | hash_including( 109 | headers: { 110 | 'FNS-OpenApi-Token' => 'token', 111 | 'FNS-OpenApi-UserToken' => Base64.strict_encode64(123.to_s) 112 | } 113 | ) 114 | ).and_call_original 115 | subject 116 | end 117 | end 118 | 119 | context 'when processing_status is not COMPLETED' do 120 | before do 121 | allow_any_instance_of(Fnsapi::Configuration).to receive(:get_message_timeout).and_return(10) 122 | allow_any_instance_of(Fnsapi::GetMessageService).to( 123 | receive(:call).and_return(processing_status: 'PROCESSED', message: get_message_result) 124 | ) 125 | end 126 | 127 | it 'raises Timeout exception' do 128 | expect { subject }.to raise_exception(Fnsapi::RequestError, 'Timeout reached') 129 | end 130 | end 131 | end 132 | end 133 | 134 | describe '#check_data' do 135 | let(:correct_message_hash) do 136 | { 137 | 'Message' => { 138 | 'tns:CheckTicketRequest' => { 139 | 'tns:CheckTicketInfo' => ticket_hash 140 | } 141 | } 142 | } 143 | end 144 | let(:stubbed_response) do 145 | OpenStruct.new( 146 | body: { 147 | send_message_response: { 148 | message_id: 'test_id' 149 | } 150 | } 151 | ) 152 | end 153 | let(:get_message_result) do 154 | { 155 | check_ticket_response: { 156 | result: { 157 | code: '200', 158 | ticket: ticket_hash.to_json 159 | } 160 | } 161 | } 162 | end 163 | 164 | before do 165 | allow_any_instance_of(Savon::Client).to receive(:call) { stubbed_response } 166 | end 167 | 168 | subject { instance.check_data(*params) } 169 | 170 | it_behaves_like 'kkt_service_with_auth_params' 171 | 172 | context 'when token is defined' do 173 | before do 174 | allow_any_instance_of(StubbedTmpStorage).to receive(:token) { 'token' } 175 | allow_any_instance_of(Fnsapi::GetMessageService).to( 176 | receive(:call).and_return(processing_status: 'COMPLETED', message: get_message_result) 177 | ) 178 | end 179 | 180 | let(:params) { [ticket, 123] } 181 | 182 | it 'returns true' do 183 | expect(subject).to eq(true) 184 | end 185 | end 186 | end 187 | 188 | describe '#get_data' do 189 | let(:correct_message_hash) do 190 | { 191 | 'Message' => { 192 | 'tns:GetTicketRequest' => { 193 | 'tns:GetTicketInfo' => ticket_hash 194 | } 195 | } 196 | } 197 | end 198 | let(:stubbed_response) do 199 | OpenStruct.new( 200 | body: { 201 | send_message_response: { 202 | message_id: 'test_id' 203 | } 204 | } 205 | ) 206 | end 207 | let(:get_message_result) do 208 | { 209 | get_ticket_response: { 210 | result: { 211 | code: '200', 212 | ticket: ticket_hash.to_json 213 | } 214 | } 215 | } 216 | end 217 | 218 | before do 219 | allow_any_instance_of(Savon::Client).to receive(:call) { stubbed_response } 220 | end 221 | 222 | subject { instance.get_data(*params) } 223 | 224 | it_behaves_like 'kkt_service_with_auth_params' 225 | 226 | context 'when token is defined' do 227 | before do 228 | allow_any_instance_of(StubbedTmpStorage).to receive(:token) { 'token' } 229 | allow_any_instance_of(Fnsapi::GetMessageService).to( 230 | receive(:call).and_return(processing_status: 'COMPLETED', message: get_message_result) 231 | ) 232 | end 233 | 234 | let(:params) { [ticket, 123] } 235 | 236 | it 'returns ticket' do 237 | expect(subject).to eq(ticket_hash) 238 | end 239 | 240 | context 'when message code is not 200' do 241 | let(:get_message_result) do 242 | { 243 | get_ticket_response: { 244 | result: { code: '400' } 245 | } 246 | } 247 | end 248 | it 'returns code' do 249 | expect(subject).to eq('400') 250 | end 251 | end 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | require 'bundler/setup' 5 | require 'fnsapi' 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = '.rspec_status' 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | 19 | class StubbedFile 20 | def truncate(_); end 21 | def write(_); end 22 | def rewind; end 23 | def read; end 24 | end 25 | 26 | class StubbedTmpStorage 27 | def initialize; end 28 | def write_token(_, _); end 29 | def token; end 30 | end 31 | 32 | class Redis 33 | def initialize(_); end 34 | def set(_, _); end 35 | def expireat(_, _); end 36 | end 37 | -------------------------------------------------------------------------------- /spec/ticket_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi::Ticket do 4 | let(:purchase_date) { DateTime.now } 5 | let(:ticket) do 6 | OpenStruct.new( 7 | fn: '123', 8 | fd: '456', 9 | pfd: '789', 10 | purchase_date: purchase_date, 11 | amount_cents: 100_500 12 | ) 13 | end 14 | let(:ticket_hash) do 15 | { 16 | fn: ticket.fn, 17 | 'fd' => ticket.fd, 18 | 'pfd' => ticket.pfd, 19 | purchase_date: ticket.purchase_date, 20 | 'amount_cents' => ticket.amount_cents 21 | } 22 | end 23 | 24 | describe '#new' do 25 | context 'when object is an instance with methods' do 26 | let(:instance) { described_class.new(ticket) } 27 | 28 | it 'creates an instance of Ticket class' do 29 | expect(instance).to be_a(described_class) 30 | end 31 | 32 | it 'sets correct field values' do 33 | expect(instance.fn).to eq(ticket.fn) 34 | expect(instance.fd).to eq(ticket.fd) 35 | expect(instance.pfd).to eq(ticket.pfd) 36 | expect(instance.purchase_date).to eq(ticket.purchase_date) 37 | expect(instance.amount_cents).to eq(ticket.amount_cents) 38 | end 39 | 40 | context 'when date is a string' do 41 | before do 42 | ticket.purchase_date = ticket.purchase_date.strftime 43 | end 44 | 45 | it 'parses it to DateTime' do 46 | expect(ticket.purchase_date).to be_a(String) 47 | expect(instance.purchase_date).to be_a(DateTime) 48 | expect(instance.purchase_date).to eq(DateTime.parse(ticket.purchase_date)) 49 | end 50 | end 51 | 52 | context 'when field has nil state' do 53 | before do 54 | ticket.purchase_date = nil 55 | end 56 | 57 | it 'raises an exception' do 58 | expect { described_class.new(ticket) }.to( 59 | raise_error(Fnsapi::FieldNotSpecifiedError, "purchase_date should be specified") 60 | ) 61 | end 62 | end 63 | end 64 | 65 | context 'when object is a Hash' do 66 | let(:instance) { described_class.new(ticket_hash) } 67 | 68 | it 'creates an instance of Ticket class' do 69 | expect(instance).to be_a(described_class) 70 | end 71 | 72 | it 'sets correct field values' do 73 | expect(instance.fn).to eq(ticket_hash[:fn]) 74 | expect(instance.fd).to eq(ticket_hash['fd']) 75 | expect(instance.pfd).to eq(ticket_hash['pfd']) 76 | expect(instance.purchase_date).to eq(ticket_hash[:purchase_date]) 77 | expect(instance.amount_cents).to eq(ticket_hash['amount_cents']) 78 | end 79 | 80 | context 'when date is a string' do 81 | before do 82 | ticket[:purchase_date] = ticket[:purchase_date].strftime 83 | end 84 | 85 | it 'parses it to DateTime' do 86 | expect(ticket[:purchase_date]).to be_a(String) 87 | expect(instance.purchase_date).to be_a(DateTime) 88 | expect(instance.purchase_date).to eq(DateTime.parse(ticket[:purchase_date])) 89 | end 90 | end 91 | 92 | context 'when field has invalid type' do 93 | before do 94 | ticket[:purchase_date] = nil 95 | end 96 | 97 | it 'raises an exception' do 98 | expect { described_class.new(ticket) }.to( 99 | raise_error(Fnsapi::FieldNotSpecifiedError, "purchase_date should be specified") 100 | ) 101 | end 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/tmp_storage_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Fnsapi::TmpStorage do 4 | let(:instance) { described_class.new } 5 | 6 | before { allow(File).to receive(:open).and_return(StubbedFile.new) } 7 | 8 | it 'opens a file' do 9 | instance 10 | expect(File).to have_received(:open).once 11 | end 12 | 13 | context 'when Rails is not defined' do 14 | it 'uses local tmp dir' do 15 | instance 16 | expect(File).to have_received(:open).with('tmp/fnsapi_tmp_credentials', 'a+') 17 | end 18 | end 19 | 20 | context 'when Rails is defined' do 21 | before do 22 | class Rails; end 23 | 24 | allow(Rails).to receive(:root).and_return(Pathname.new('/rails')) 25 | allow_any_instance_of(Pathname).to receive(:join).and_return('/rails/tmp/fnsapi_tmp_credentials') 26 | end 27 | 28 | after { Object.send(:remove_const, :Rails) } 29 | 30 | it 'creates file in Rails tmp dir' do 31 | expect_any_instance_of(Pathname).to receive(:join).with('tmp', 'fnsapi_tmp_credentials') 32 | instance 33 | expect(File).to have_received(:open).with('/rails/tmp/fnsapi_tmp_credentials', 'a+') 34 | end 35 | end 36 | 37 | describe '#write_token' do 38 | subject(:write_token) { instance.write_token(token, expire_at) } 39 | let(:token_string) { { token: token, expire_at: expire_at }.to_json } 40 | let(:token) { 'token' } 41 | let(:expire_at) { Time.now } 42 | 43 | it 'clears file' do 44 | expect_any_instance_of(StubbedFile).to receive(:truncate).with(0) 45 | write_token 46 | end 47 | 48 | it 'writes new data in file' do 49 | expect_any_instance_of(StubbedFile).to receive(:write).with(token_string) 50 | write_token 51 | end 52 | 53 | it 'rewinds file' do 54 | expect_any_instance_of(StubbedFile).to receive(:rewind) 55 | write_token 56 | end 57 | end 58 | 59 | describe '#token' do 60 | before { allow_any_instance_of(StubbedFile).to receive(:read).and_return(token_string) } 61 | 62 | let(:token_string) { { token: token, expire_at: expire_at }.to_json } 63 | let(:token) { 'token' } 64 | 65 | context 'when not expired' do 66 | let(:expire_at) { DateTime.now + 100_000 } 67 | 68 | it 'returns token' do 69 | expect(instance.token).to eq('token') 70 | end 71 | end 72 | 73 | context 'when expired' do 74 | let(:expire_at) { DateTime.now - 100_000 } 75 | 76 | it 'returns nil' do 77 | expect(instance.token).to eq(nil) 78 | end 79 | end 80 | 81 | context 'when JSON string invalid' do 82 | let(:token_string) { '' } 83 | 84 | it 'returns nil' do 85 | expect(instance.token).to eq(nil) 86 | end 87 | end 88 | end 89 | end 90 | --------------------------------------------------------------------------------