qiwi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ require 'logger'
2
+
3
+ module Qiwi
4
+
5
+ class Config
6
+ attr_accessor :login, :password, :endpoint, :logger, :transaction_handler
7
+
8
+ def logger
9
+ @logger ||= Logger.new(STDERR)
10
+ end
11
+ end
12
+
13
+ def self.configure
14
+ yield config
15
+ end
16
+
17
+ def self.config
18
+ @config ||= Config.new
19
+ end
20
+
21
+ end
@@ -0,0 +1,8 @@
1
+ require 'rails'
2
+ require 'qiwi/server'
3
+
4
+ module Qiwi
5
+ class Engine < Rails::Engine
6
+ endpoint Qiwi::Server.new
7
+ end
8
+ end
@@ -0,0 +1,50 @@
1
+ require 'qiwi/transaction'
2
+
3
+ module Qiwi
4
+ class Handler
5
+ def self.call(txn, status)
6
+ new(txn, status).handle
7
+ end
8
+
9
+ attr_reader :txn, :status
10
+ def initialize(txn, status)
11
+ @txn = Transaction.new(txn)
12
+ @status = status
13
+ end
14
+
15
+ def handle
16
+ check_transaction
17
+ end
18
+
19
+ def check_transaction
20
+ unless txn.exists?
21
+ logger.error "Transaction doesn't exist: #{txn.txn}"
22
+ return 210
23
+ end
24
+
25
+ if status != txn.remote_status
26
+ logger.error "Stati don't match: #{txn.status} vs. #{status}"
27
+ return 300
28
+ end
29
+
30
+ unless txn.valid_amount?
31
+ logger.error "Incorrect amount: #{txn.amount}"
32
+ return 241
33
+ end
34
+
35
+ if txn.valid?
36
+ return 0
37
+ else
38
+ logger.error "Unknown error: #{txn.inspect}"
39
+ return 300
40
+ end
41
+
42
+ ensure
43
+ txn.commit!
44
+ end
45
+
46
+ def logger
47
+ Qiwi.logger
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,144 @@
1
+ require 'ostruct'
2
+ require 'active_model/validations'
3
+
4
+ module Qiwi
5
+ module Request
6
+
7
+ class Base
8
+ include ActiveModel::Validations
9
+
10
+ def self.inherited(klass)
11
+ klass.attributes :login, :password
12
+ klass.validates_presence_of :login, :password
13
+ end
14
+
15
+ def self.attributes(*attrs)
16
+ if attrs.empty?
17
+ @_attributes
18
+ else
19
+ @_attributes ||= []
20
+ @_attributes += attrs
21
+ attr_accessor(*attrs)
22
+ end
23
+ end
24
+
25
+ # See concrete classes for parameters description.
26
+ #
27
+ # @param [Qiwi::Client] client
28
+ # @param [Hash] params
29
+ def initialize(client, params)
30
+ self.class.attributes.each { |attr| send(:"#{attr}=", params[attr]) }
31
+ @login, @password = client.login, client.password
32
+ end
33
+
34
+ def body
35
+ with_envelope { |xml| soap_body(xml) }
36
+ end
37
+
38
+ def with_envelope(&block)
39
+ Nokogiri::XML::Builder.new do |xml|
40
+ xml.Envelope("xmlns:soapenv" => "http://www.w3.org/2003/05/soap-envelope",
41
+ "xmlns:tns" => "http://server.ishop.mw.ru/") do
42
+ xml.parent.namespace = xml.parent.namespace_definitions.first
43
+ xml.Header
44
+ xml.Body(&block)
45
+ end
46
+ end
47
+ end
48
+
49
+ def soap_body(xml)
50
+ xml['tns'].send(method) do
51
+ self.class.attributes.each do |attr|
52
+ # Underscore, so 'comment' is used as a parameter
53
+ # some ugly way to remove the namespace
54
+ xml.send("#{attr}_", send(attr)).instance_variable_get(:@node).namespace = nil
55
+ end
56
+ end
57
+ end
58
+
59
+ # The SOAP method name
60
+ def method
61
+ @method ||= self.class.to_s.split('::').last.camelize(:lower)
62
+ end
63
+
64
+ # Make sense of what is returned by the server
65
+ #
66
+ # @param [Nokogiri::XML::Document] xml
67
+ def result_from_xml(xml)
68
+ xml.xpath("//#{method}Response/#{method}Result").text.to_i
69
+ end
70
+ end
71
+
72
+ class CreateBill < Base
73
+ attributes :user, :amount, :comment, :txn, :lifetime, :alarm, :create
74
+
75
+ validates_presence_of :user, :amount, :txn
76
+ validates_numericality_of :amount
77
+ validates_length_of :comment, :maximum => 255
78
+ validates_length_of :txn, :maximum => 30
79
+ validates_format_of :lifetime, :with => /^\d{2}\.\d{2}\.\d{4}\s\d{2}:\d{2}:\d{2}$/, :allow_nil => true
80
+
81
+ # @param [Hash] params
82
+ # @option params [String] :user e.g. a phone number
83
+ # @option params [Float] :amount
84
+ # @option params [String] :comment
85
+ # @option params [String] :txn unique bill identifier
86
+ # @option params [String, Time] :lifetime in "dd.MM.yyyy HH:mm:ss" format
87
+ # @option params [Fixnum] :alarm
88
+ # @option params [Boolean] :create
89
+ def initialize(client, params)
90
+ super
91
+ @alarm ||= 0
92
+ @create = true if @create.nil?
93
+ @lifetime = @lifetime.strftime('%d.%m.%Y %H:%M:%S') if @lifetime.respond_to?(:strftime)
94
+ end
95
+ end
96
+
97
+ class CancelBill < Base
98
+ attributes :txn
99
+ end
100
+
101
+ class CheckBill < Base
102
+ attributes :txn
103
+
104
+ # @example
105
+ # {:user=>"name", :amount=>1000.0, :date=>"07.09.2012 13:33", :status=>60}
106
+ def result_from_xml(xml)
107
+ el = xml.xpath("//checkBillResponse")
108
+ OpenStruct.new({
109
+ user: el.at('user').text,
110
+ amount: el.at('amount').text.to_f,
111
+ date: el.at('date').text,
112
+ lifetime: el.at('lifetime').text,
113
+ status: el.at('status').text.to_i
114
+ })
115
+ end
116
+ end
117
+
118
+ class GetBillList < Base
119
+ attributes :dateFrom, :dateTo, :status
120
+
121
+ validates_presence_of :status
122
+ validates_numericality_of :status
123
+
124
+ # @param [Hash] params
125
+ # @option params [String] :user e.g. a phone number
126
+ # @option params [Time] :date_from
127
+ # @option params [Time] :date_to
128
+ # @option params [Fixnum] :status
129
+ def initialize(client, hash)
130
+ super
131
+ @dateFrom = hash[:date_from].strftime('%d.%m.%Y %H:%M:%S') if hash[:date_from]
132
+ @dateTo = hash[:date_to].strftime('%d.%m.%Y %H:%M:%S') if hash[:date_to]
133
+ end
134
+
135
+ def result_from_xml(xml)
136
+ el = xml.xpath("//getBillListResponse")
137
+ OpenStruct.new({
138
+ txns: el.at('txns').text,
139
+ count: el.at('count').text.to_i
140
+ })
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,24 @@
1
+ module Qiwi
2
+ module Response
3
+ STATUS = {
4
+ 50 => :set,
5
+ 52 => :being_processed,
6
+ 60 => :paid,
7
+ 150 => :cancelled_by_terminal,
8
+ 151 => :cancelled_no_auth,
9
+ 160 => :cancelled,
10
+ 161 => :cancelled_expired
11
+ }
12
+ STATUS.default_proc = lambda do |hash, status|
13
+ if status >= 51 and status <= 59
14
+ hash[52]
15
+ elsif status < 50
16
+ hash[50]
17
+ elsif status > 100
18
+ hash[160]
19
+ else
20
+ :unknown
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,78 @@
1
+ require 'nokogiri'
2
+ require 'digest/md5'
3
+ require 'qiwi/handler'
4
+
5
+ module Qiwi
6
+ class Server
7
+ attr_accessor :handler
8
+
9
+ # Creates a new instance of Rack application
10
+ #
11
+ # @param [String] login
12
+ # @param [String] password
13
+ # @param [Proc] handler must return 0 on success
14
+ def initialize(login = nil, password = nil, handler = nil)
15
+ @login = login || Qiwi.config.login
16
+ @password = password || Qiwi.config.password
17
+ @handler = handler || Qiwi::Handler
18
+ end
19
+
20
+ def call(env)
21
+ @logger = env['rack.logger']
22
+
23
+ if env['QUERY_STRING'] == 'wsdl'
24
+ body = File.read(File.join(File.dirname(__FILE__), 'IShopClientWS.wsdl'))
25
+ else
26
+ body = env['rack.input'].read
27
+ result = handle_soap_body(body)
28
+ body = <<-EOF
29
+ <soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:cli="http://client.ishop.mw.ru/">
30
+ <soap:Body>
31
+ <cli:updateBillResponse>
32
+ <updateBillResult>#{result}</updateBillResult>
33
+ </cli:updateBillResponse>
34
+ </soap:Body>
35
+ </soap:Envelope>
36
+ EOF
37
+ end
38
+ headers = {'Content-Type' => 'application/soap+xml', 'Cache-Control' => 'no-cache'}
39
+ [200, headers, [body]]
40
+ end
41
+
42
+ private
43
+ def handle_soap_body(body)
44
+ xml = Nokogiri::XML(body).remove_namespaces!
45
+ nodeset = xml.xpath('//updateBill')
46
+ return 300 if nodeset.empty?
47
+
48
+ params = %w[login password txn status].each_with_object({}) do |field, h|
49
+ h[field.to_sym] = nodeset.at(field).text
50
+ end
51
+
52
+ unless authorized?(params)
53
+ logger.info "Unauthorized: #{params.inspect}"
54
+ return 150
55
+ end
56
+
57
+ txn, status = params.values_at(:txn, :status)
58
+ handler.call(txn, status.to_i).tap do |res|
59
+ logger.info "Qiwi handler returned #{res}"
60
+ end
61
+ rescue => e
62
+ # Unknown error
63
+ logger.error "Error: #{e.message}\n#{e.backtrace.slice(0,2).join("\n")}"
64
+ return 300
65
+ end
66
+
67
+ # Check if the password matches
68
+ def authorized?(params)
69
+ params[:login] == @login and
70
+ params[:password] == Digest::MD5.hexdigest(params[:txn] + Digest::MD5.hexdigest(@password).upcase).upcase
71
+ end
72
+
73
+ def logger
74
+ @logger ||= Logger.new(STDERR)
75
+ end
76
+
77
+ end
78
+ end
@@ -0,0 +1,93 @@
1
+ require 'qiwi/client'
2
+ require 'observer'
3
+ require 'active_model'
4
+ require 'active_support/core_ext/module/delegation'
5
+
6
+ module Qiwi
7
+ class Transaction
8
+ include Observable
9
+ include ActiveModel::Validations
10
+
11
+ class CorrectAmountValidator < ActiveModel::Validator
12
+ def validate(txn)
13
+ unless txn.remote_amount.to_i == txn.amount.to_i
14
+ txn.errors.add(:amount, :invalid)
15
+ end
16
+ end
17
+ end
18
+
19
+ validates_presence_of :txn
20
+ validates_presence_of :persisted, :remote, :message => 'transaction not found'
21
+ validates :amount, :numericality => true, :correct_amount => true
22
+
23
+ delegate :amount, :status, :to => :remote, :prefix => true, :allow_nil => true
24
+
25
+ # Transaction id
26
+ attr_reader :txn
27
+
28
+ # Finder should respond_to?(:find_by_txn) and return an object,
29
+ # which can respond_to?(:amount)
30
+ attr_accessor :finder
31
+
32
+ def initialize(txn)
33
+ @txn = txn
34
+
35
+ # A logging observer
36
+ add_observer(self, :log_transaction)
37
+
38
+ if block_given?
39
+ yield self
40
+ else
41
+ Qiwi.config.transaction_handler.call(self) if Qiwi.config.transaction_handler
42
+ end
43
+ end
44
+
45
+ def inspect
46
+ error_msgs = errors.full_messages.join(', ')
47
+ %{<Qiwi::Transaction id: #{txn}, remote: #{remote.inspect} persisted: #{persisted.inspect} errors: #{error_msgs}}
48
+ end
49
+
50
+ def commit!
51
+ changed
52
+ notify_observers(self)
53
+ end
54
+
55
+ def exists?
56
+ !!persisted
57
+ end
58
+
59
+ def valid_amount?
60
+ valid?
61
+ errors[:amount].empty?
62
+ end
63
+
64
+ def amount
65
+ persisted.amount if exists?
66
+ end
67
+
68
+ def log_transaction(transaction)
69
+ Qiwi.logger.info "Transaction update: #{transaction.inspect}"
70
+ end
71
+
72
+ def remote
73
+ @remote ||= Qiwi::Client.new.check_bill(txn: txn)
74
+ end
75
+
76
+ def persisted
77
+ @persisted ||= find(txn)
78
+ end
79
+
80
+ def find(txn)
81
+ finder.find_by_txn(txn) if finder
82
+ end
83
+
84
+ end
85
+ end
86
+
87
+ # # Example:
88
+ # Qiwi.configure do |config|
89
+ # config.transaction_handler = lambda do |txn|
90
+ # txn.finder = PendingTransactions
91
+ # txn.add_observer(TransactionHandler.new)
92
+ # end
93
+ # end
@@ -0,0 +1,102 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "qiwi"
8
+ s.version = "0.1.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Roman Shterenzon"]
12
+ s.date = "2012-10-31"
13
+ s.description = "Qiwi payments solution"
14
+ s.email = "romanbsd@yahoo.com"
15
+ s.extra_rdoc_files = [
16
+ "LICENSE.txt",
17
+ "README.md"
18
+ ]
19
+ s.files = [
20
+ ".rspec",
21
+ "Gemfile",
22
+ "Gemfile.lock",
23
+ "Guardfile",
24
+ "LICENSE.txt",
25
+ "README.md",
26
+ "Rakefile",
27
+ "VERSION",
28
+ "config/routes.rb",
29
+ "lib/qiwi.rb",
30
+ "lib/qiwi/IShopClientWS.wsdl",
31
+ "lib/qiwi/client.rb",
32
+ "lib/qiwi/config.rb",
33
+ "lib/qiwi/engine.rb",
34
+ "lib/qiwi/handler.rb",
35
+ "lib/qiwi/request.rb",
36
+ "lib/qiwi/response.rb",
37
+ "lib/qiwi/server.rb",
38
+ "lib/qiwi/transaction.rb",
39
+ "qiwi.gemspec",
40
+ "spec/client_spec.rb",
41
+ "spec/handler_spec.rb",
42
+ "spec/qiwi_spec.rb",
43
+ "spec/request_spec.rb",
44
+ "spec/server_spec.rb",
45
+ "spec/spec_helper.rb",
46
+ "spec/transaction_spec.rb"
47
+ ]
48
+ s.homepage = "http://github.com/romanbsd/qiwi"
49
+ s.licenses = ["MIT"]
50
+ s.require_paths = ["lib"]
51
+ s.rubygems_version = "1.8.24"
52
+ s.summary = "Qiwi payments solution"
53
+
54
+ if s.respond_to? :specification_version then
55
+ s.specification_version = 3
56
+
57
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
58
+ s.add_runtime_dependency(%q<activesupport>, [">= 0"])
59
+ s.add_runtime_dependency(%q<activemodel>, [">= 0"])
60
+ s.add_runtime_dependency(%q<nokogiri>, [">= 0"])
61
+ s.add_runtime_dependency(%q<faraday>, [">= 0"])
62
+ s.add_runtime_dependency(%q<rack>, [">= 0"])
63
+ s.add_development_dependency(%q<webmock>, [">= 0"])
64
+ s.add_development_dependency(%q<rspec>, ["~> 2.11.0"])
65
+ s.add_development_dependency(%q<yard>, ["~> 0.7"])
66
+ s.add_development_dependency(%q<bundler>, ["~> 1.0"])
67
+ s.add_development_dependency(%q<jeweler>, ["~> 1.8.4"])
68
+ s.add_development_dependency(%q<guard-rspec>, [">= 0"])
69
+ s.add_development_dependency(%q<rb-fsevent>, ["~> 0.9.1"])
70
+ s.add_development_dependency(%q<growl>, [">= 0"])
71
+ else
72
+ s.add_dependency(%q<activesupport>, [">= 0"])
73
+ s.add_dependency(%q<activemodel>, [">= 0"])
74
+ s.add_dependency(%q<nokogiri>, [">= 0"])
75
+ s.add_dependency(%q<faraday>, [">= 0"])
76
+ s.add_dependency(%q<rack>, [">= 0"])
77
+ s.add_dependency(%q<webmock>, [">= 0"])
78
+ s.add_dependency(%q<rspec>, ["~> 2.11.0"])
79
+ s.add_dependency(%q<yard>, ["~> 0.7"])
80
+ s.add_dependency(%q<bundler>, ["~> 1.0"])
81
+ s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
82
+ s.add_dependency(%q<guard-rspec>, [">= 0"])
83
+ s.add_dependency(%q<rb-fsevent>, ["~> 0.9.1"])
84
+ s.add_dependency(%q<growl>, [">= 0"])
85
+ end
86
+ else
87
+ s.add_dependency(%q<activesupport>, [">= 0"])
88
+ s.add_dependency(%q<activemodel>, [">= 0"])
89
+ s.add_dependency(%q<nokogiri>, [">= 0"])
90
+ s.add_dependency(%q<faraday>, [">= 0"])
91
+ s.add_dependency(%q<rack>, [">= 0"])
92
+ s.add_dependency(%q<webmock>, [">= 0"])
93
+ s.add_dependency(%q<rspec>, ["~> 2.11.0"])
94
+ s.add_dependency(%q<yard>, ["~> 0.7"])
95
+ s.add_dependency(%q<bundler>, ["~> 1.0"])
96
+ s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
97
+ s.add_dependency(%q<guard-rspec>, [">= 0"])
98
+ s.add_dependency(%q<rb-fsevent>, ["~> 0.9.1"])
99
+ s.add_dependency(%q<growl>, [">= 0"])
100
+ end
101
+ end
102
+