tap-clutch 0.0.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: b98dba7a611e56ae431cb914fb2bd85d57030f6c
4
+ data.tar.gz: aaf74f13fca725477914f512df762bf2f11cc942
5
+ SHA512:
6
+ metadata.gz: fe07a0c3165b12aaddd0922463434d4a90a9145acd0bb9cc4829ad27684ee33511769ae0f0664386b5756b0994425e68fb75f3e98d1254e5c269a44c140fad13
7
+ data.tar.gz: f60047ee073300619cb378b39b6f44d4e8077c8d01db1ae36923b99af092f400123f7223f69c8ae6b18eb131c6dfacfa74e6347956b0bc2eda40c8f1f01986ae
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 follain
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,31 @@
1
+ tap-clutch
2
+ ==========
3
+
4
+ ## A [singer.io](http://singer.io) tap to extract data from [Clutch](http://clutch.com) and load into any Singer target, like Stitch or CSV
5
+
6
+ # Configuration
7
+
8
+ {
9
+ "api_key": "api_key",
10
+ "api_secret": "secret",
11
+ "api_base": "https://api.clutch.com/merchant/",
12
+ "brand": "clutch_brand",
13
+ "location": "clutch_location",
14
+ "card_set_id": "clutch_card_set_id",
15
+ "terminal": "clutch_terminal",
16
+ "username": "clutch_portal_user_id",
17
+ "password": "clutch_portal_password"
18
+ }
19
+
20
+ # Usage (with [Stitch target](https://github.com/singer-io/target-stitch))
21
+
22
+ > bundle exec tap-clutch
23
+ Usage: tap-clutch [options]
24
+ -c, --config config_file Set config file (json)
25
+ -s, --state state_file Set state file (json)
26
+ -h, --help Displays help
27
+ -v, --verbose Enables verbose logging to STDERR
28
+
29
+ > pip install target-stitch
30
+ > gem install tap-clutch
31
+ > bundle exec tap-clutch -c config.clutch.json -s state.json | target-stitch --config config.stitch.json | tail -1 > state.new.json
data/bin/tap-clutch ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler'
4
+
5
+ Bundler.require
6
+
7
+ require_relative '../lib/runner'
8
+
9
+ TapClutch::Runner.new(ARGV, stream: $stdout).perform
data/lib/client.rb ADDED
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'clutch'
4
+ require 'faraday-cookie_jar'
5
+ require 'faraday_middleware'
6
+ require 'active_support/time'
7
+
8
+ require_relative 'models/card'
9
+
10
+ module TapClutch
11
+ LIMIT = 25
12
+ KEYS = %i[
13
+ api_key
14
+ api_secret
15
+ api_base
16
+ brand
17
+ location
18
+ terminal
19
+ verbose
20
+ state
21
+ stream
22
+ username
23
+ password
24
+ ].freeze
25
+
26
+ # rubocop:disable Metrics/BlockLength
27
+ Client = Struct.new(*KEYS) do
28
+ def initialize(**kwargs)
29
+ super(*members.map { |k| kwargs[k] })
30
+ Clutch.configure do |c|
31
+ c.clutch_api_key = api_key
32
+ c.clutch_api_secret = api_secret
33
+ c.clutch_api_base = api_base
34
+ c.clutch_brand = brand
35
+ c.clutch_location = location
36
+ c.clutch_terminal = terminal
37
+ end
38
+ end
39
+
40
+ def faraday
41
+ @faraday ||= build_faraday.tap do |f|
42
+ f.post '/authenticate/userpass', username: username, password: password
43
+ end
44
+ end
45
+
46
+ def build_faraday
47
+ # TODO: Extract clutch portal URL to config.json
48
+ Faraday.new(url: 'https://portal.clutch.com') do |builder|
49
+ builder.use :cookie_jar
50
+ builder.request :url_encoded
51
+ builder.response :json
52
+ builder.adapter Faraday.default_adapter
53
+ end
54
+ end
55
+
56
+ def process(start_date = '2018-01-11')
57
+ start_date = Date.parse(start_date) if start_date.is_a? String
58
+ (start_date..Time.current.getlocal.to_date).each do |date|
59
+ process_date date
60
+ end
61
+ end
62
+
63
+ def output(hash)
64
+ stream.puts JSON.generate(hash)
65
+ end
66
+
67
+ private
68
+
69
+ def process_date(date)
70
+ response = search(date)
71
+ card_numbers = response.body['lookerData']['data'].map { |x| x[2] }.uniq
72
+ cards = card_numbers.map { |n| Models::Card.fetch(n) }
73
+ output_records Models::Card, cards
74
+ output_state date
75
+ end
76
+
77
+ def search(date)
78
+ faraday.post do |req|
79
+ req.url '/transactions/search.json'
80
+ req.body = {
81
+ searchType: 'processed',
82
+ brandId: 7532,
83
+ groupId: 7533,
84
+ transactionTypes: '3,5,9',
85
+ currentRange: 'Custom Range',
86
+ timeFrom: '00:00:00 am',
87
+ timeTo: '00:00:00 am',
88
+ dateFrom: date.strftime('%Y-%m-%d'),
89
+ dateTo: (date + 1).strftime('%Y-%m-%d')
90
+ }
91
+ end
92
+ end
93
+
94
+ def output_records(_model, records)
95
+ records.compact.each do |record|
96
+ record.records.flatten.each do |model_record|
97
+ output model_record
98
+ end
99
+ end
100
+ end
101
+
102
+ def output_state(date)
103
+ output type: :STATE, value: { start_date: date }
104
+ end
105
+
106
+ def get(model, offset)
107
+ model.fetch offset
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../schema'
4
+
5
+ module TapClutch
6
+ module Models
7
+ Base = Struct.new(:data, :client) do # rubocop:disable Metrics/BlockLength
8
+ def self.subclasses
9
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
10
+ end
11
+
12
+ def self.stream
13
+ raise 'Implement #stream in a subclass'
14
+ end
15
+
16
+ def self.key_property
17
+ :id
18
+ end
19
+
20
+ def self.schema(&block)
21
+ @schema ||= ::TapClutch::Schema.new(stream, key_property)
22
+ @schema.instance_eval(&block) if block_given?
23
+ @schema.to_hash
24
+ end
25
+
26
+ def transform
27
+ data.dup
28
+ end
29
+
30
+ def base_record
31
+ {
32
+ type: 'RECORD',
33
+ stream: self.class.stream,
34
+ record: transform
35
+ }
36
+ end
37
+
38
+ def extra_records
39
+ []
40
+ end
41
+
42
+ def records
43
+ [base_record] + extra_records.compact.flat_map(&:records)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'transaction'
5
+
6
+ module TapClutch
7
+ module Models
8
+ # Models a Clutch Card
9
+ class Card < Base
10
+ def self.key_property
11
+ :cardNumber
12
+ end
13
+
14
+ def self.stream
15
+ 'cards'
16
+ end
17
+
18
+ schema do
19
+ string :cardNumber, :not_null
20
+ string :cardSetId
21
+ array :balances
22
+ end
23
+
24
+ def self.fetch(card_number)
25
+ response = Clutch.client.post(
26
+ '/search',
27
+ limit: 1,
28
+ offset: 0,
29
+ filters: {
30
+ cardNumber: card_number
31
+ },
32
+ returnFields: {
33
+ balances: true,
34
+ activationDate: true
35
+ }
36
+ )
37
+
38
+ new(response.cards.first) if response.cards.first
39
+ end
40
+
41
+ def extra_records
42
+ Transaction.history(data.cardNumber)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require 'byebug'
5
+ require 'active_support/time'
6
+
7
+ module TapClutch
8
+ module Models
9
+ # Models a Clutch Transaction
10
+ class Transaction < Base
11
+ def self.key_property
12
+ :transactionId
13
+ end
14
+
15
+ def self.stream
16
+ 'transactions'
17
+ end
18
+
19
+ def self.history(card_number)
20
+ response = Clutch.client.post(
21
+ '/cardHistory',
22
+ limit: 100,
23
+ offset: 0,
24
+ cardNumber: card_number,
25
+ restrictTransactionTypes: %w[ALLOCATE UPDATE_BALANCE]
26
+ )
27
+
28
+ response.transactions.map do |transaction|
29
+ new(transaction.merge(cardNumber: card_number))
30
+ end
31
+ end
32
+
33
+ schema do
34
+ string :transactionId, :not_null
35
+ string :cardNumber, :not_null
36
+ string :requestRef
37
+ string :callType
38
+ boolean :isLegacy
39
+ string :location
40
+ string :transactionTime
41
+ array :balanceUpdates
42
+ end
43
+
44
+ def transform
45
+ Time.zone = Time.now.zone
46
+
47
+ super.tap do |data|
48
+ data.merge! 'transactionTime' =>
49
+ Time.zone.at(data['transactionTime'] / 1000)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/runner.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'optparse'
5
+
6
+ require_relative 'client'
7
+ require_relative 'schema'
8
+ require_relative 'models/base'
9
+ require_relative 'models/transaction'
10
+
11
+ module TapClutch
12
+ # Kicks off tap-clutch process
13
+ class Runner
14
+ attr_reader :config_filename
15
+ attr_reader :state_filename
16
+ attr_reader :stream
17
+ attr_reader :verbose
18
+
19
+ def initialize(argv, stream: $stderr, config: nil, state: nil)
20
+ @stream = stream
21
+ @config = config
22
+ @state = state
23
+ parser.parse! argv
24
+ end
25
+
26
+ def perform
27
+ return stream.puts(parser) if config.keys.empty?
28
+ output_schemata
29
+ client.process state['start_date']
30
+ end
31
+
32
+ def config
33
+ @config ||= read_json(config_filename)
34
+ end
35
+
36
+ def state
37
+ @state ||= read_json(state_filename)
38
+ end
39
+
40
+ private
41
+
42
+ def output_schemata
43
+ TapClutch::Models::Base.subclasses.each do |model|
44
+ client.output model.schema
45
+ end
46
+ end
47
+
48
+ # rubocop: disable Metrics/AbcSize
49
+ def client
50
+ @client ||= TapClutch::Client.new(
51
+ api_key: config['api_key'],
52
+ api_secret: config['api_secret'],
53
+ api_base: config['api_base'],
54
+ brand: config['brand'],
55
+ location: config['location'],
56
+ terminal: config['terminal'],
57
+ username: config['username'],
58
+ password: config['password'],
59
+ verbose: verbose,
60
+ state: Concurrent::Hash.new.merge!(state),
61
+ stream: stream
62
+ )
63
+ end
64
+ # rubocop: enable Metrics/AbcSize
65
+
66
+ def read_json(filename)
67
+ return JSON.parse(File.read(filename)) if filename
68
+ {}
69
+ end
70
+
71
+ def parser
72
+ @parser ||= OptionParser.new do |opts|
73
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
74
+ opts.on('-c', '--config filename', 'Set config file (json)') do |config|
75
+ @config_filename = config
76
+ end
77
+
78
+ opts.on('-s', '--state filename', 'Set state file (json)') do |state|
79
+ @state_filename = state
80
+ end
81
+
82
+ opts.on('-v', '--verbose', 'Enables verbose logging to STDERR') do
83
+ @verbose = true
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
data/lib/schema.rb ADDED
@@ -0,0 +1,66 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ module TapClutch
5
+ # Models a JSON Schema required for Singer taps
6
+ class Schema
7
+ attr_reader :stream, :key_property
8
+
9
+ def initialize(stream, key_property)
10
+ @stream = stream
11
+ @key_property = key_property
12
+ end
13
+
14
+ # Models JSON Schema types
15
+ class Types
16
+ def self.method_missing(method, *args)
17
+ return super unless %I[
18
+ array
19
+ number
20
+ object
21
+ string
22
+ boolean
23
+ ].include?(method)
24
+
25
+ types = [method.to_sym]
26
+ types << :null unless args.include?(:not_null)
27
+
28
+ {
29
+ type: types.one? ? types.first : types
30
+ }.tap do |hash|
31
+ hash[:format] = 'date-time' if args.include?(:datetime)
32
+ end
33
+ end
34
+
35
+ def self.respond_to_missing?(_method, *_args)
36
+ true
37
+ end
38
+
39
+ def self.datetime(*args)
40
+ args << :datetime
41
+ string(*args)
42
+ end
43
+ end
44
+
45
+ %i[string number array object datetime boolean].each do |type|
46
+ define_method type do |name, *options|
47
+ properties[name.to_sym] = Types.send(type, *options)
48
+ end
49
+ end
50
+
51
+ def properties
52
+ @properties ||= {}
53
+ end
54
+
55
+ def to_hash
56
+ {
57
+ type: :SCHEMA,
58
+ stream: stream,
59
+ key_properties: [key_property],
60
+ schema: {
61
+ properties: properties
62
+ }
63
+ }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TapClutch
4
+ VERSION = '0.0.1'
5
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tap-clutch
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Joe Lind
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-06-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: clutch-client
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: concurrent-ruby
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 1.0.2
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - "~>"
56
+ - !ruby/object:Gem::Version
57
+ version: '1.0'
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.0.2
61
+ - !ruby/object:Gem::Dependency
62
+ name: faraday-cookie_jar
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.0.6
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.0.6
75
+ - !ruby/object:Gem::Dependency
76
+ name: faraday_middleware
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.12.2
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 0.12.2
89
+ description: Stream Clutch records to a Singer target, such as Stitch
90
+ email: joelind@gmail.com
91
+ executables:
92
+ - tap-clutch
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - LICENSE
97
+ - README.md
98
+ - bin/tap-clutch
99
+ - lib/client.rb
100
+ - lib/models/base.rb
101
+ - lib/models/card.rb
102
+ - lib/models/transaction.rb
103
+ - lib/runner.rb
104
+ - lib/schema.rb
105
+ - lib/tap_clutch/version.rb
106
+ homepage: https://github.com/Follain/tap-clutch
107
+ licenses:
108
+ - MIT
109
+ metadata: {}
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubyforge_project:
126
+ rubygems_version: 2.5.2
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Singer.io tap for Clutch Loyalty
130
+ test_files: []