killbill-chartmogul 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/NEWS ADDED
@@ -0,0 +1,2 @@
1
+ 0.0.1
2
+ Initial release for Kill Bill 0.17.x
@@ -0,0 +1,37 @@
1
+ killbill-chartmogul-plugin
2
+ ==========================
3
+
4
+ Plugin to mirror Kill Bill data into ChartMogul.
5
+
6
+ Release builds are available on [Maven Central](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22org.kill-bill.billing.plugin.ruby%22%20AND%20a%3A%22chartmogul-plugin%22) with coordinates `org.kill-bill.billing.plugin.ruby:chartmogul-plugin`.
7
+
8
+ Kill Bill compatibility
9
+ -----------------------
10
+
11
+ | Plugin version | Kill Bill version |
12
+ | -------------: | ----------------: |
13
+ | 0.0.y | 0.18.z |
14
+
15
+ Configuration
16
+ -------------
17
+
18
+ ```
19
+ curl -v \
20
+ -X POST \
21
+ -u admin:password \
22
+ -H 'X-Killbill-ApiKey: bob' \
23
+ -H 'X-Killbill-ApiSecret: lazar' \
24
+ -H 'X-Killbill-CreatedBy: admin' \
25
+ -H 'Content-Type: text/plain' \
26
+ -d ':chartmogul:
27
+ :account_token: 'account_token'
28
+ :secret_key: 'secret_key'' \
29
+ http://127.0.0.1:8080/1.0/kb/tenants/uploadPluginConfig/killbill-chartmogul
30
+ ```
31
+
32
+ Your Account Token and Secret Key are available from the administration section of your ChartMogul account.
33
+
34
+ Usage
35
+ -----
36
+
37
+ The plugin will automatically listen to all events, and create or update the associated data in ChartMogul.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env rake
2
+
3
+ # Install tasks to build and release the plugin
4
+ require 'bundler/setup'
5
+ Bundler::GemHelper.install_tasks
6
+
7
+ # Install test tasks
8
+ require 'rspec/core/rake_task'
9
+ namespace :test do
10
+ desc 'Run RSpec tests'
11
+ RSpec::Core::RakeTask.new do |task|
12
+ task.name = 'spec'
13
+ task.pattern = './spec/*/*_spec.rb'
14
+ end
15
+
16
+ namespace :remote do
17
+ desc 'Run RSpec remote tests'
18
+ RSpec::Core::RakeTask.new do |task|
19
+ task.name = 'spec'
20
+ task.pattern = './spec/*/remote/*_spec.rb'
21
+ end
22
+ end
23
+ end
24
+
25
+ # Install tasks to package the plugin for Killbill
26
+ require 'killbill/rake_task'
27
+ Killbill::PluginHelper.install_tasks
28
+
29
+ # Run tests by default
30
+ task :default => 'test:spec'
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,3 @@
1
+ :chartmogul:
2
+ :account_token: <%= ENV['ACCOUNT_TOKEN'] %>
3
+ :secret_key: <%= ENV['SECRET_KEY'] %>
@@ -0,0 +1,4 @@
1
+ require 'chart_mogul'
2
+ require 'chart_mogul/application'
3
+
4
+ run Sinatra::Application
@@ -0,0 +1,52 @@
1
+ version = File.read(File.expand_path('../VERSION', __FILE__)).strip
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'killbill-chartmogul'
5
+ s.version = version
6
+ s.summary = 'Plugin to mirror Kill Bill data into ChartMogul'
7
+ s.description = 'Kill Bill notification plugin for ChartMogul.'
8
+
9
+ s.required_ruby_version = '>= 2'
10
+
11
+ s.license = 'Apache License (2.0)'
12
+
13
+ s.author = 'Kill Bill core team'
14
+ s.email = 'killbilling-users@googlegroups.com'
15
+ s.homepage = 'http://killbill.io'
16
+
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
+ s.bindir = 'bin'
20
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
21
+ s.require_paths = ['lib']
22
+
23
+ s.rdoc_options << '--exclude' << '.'
24
+
25
+ s.add_dependency 'killbill', '~> 8.1'
26
+
27
+ s.add_dependency 'chartmogul-ruby', '~> 0.1'
28
+
29
+ s.add_dependency 'sinatra', '~> 1.3.4'
30
+ s.add_dependency 'thread_safe', '~> 0.3.4'
31
+ s.add_dependency 'activerecord', '~> 4.1.0'
32
+ if defined?(JRUBY_VERSION)
33
+ s.add_dependency 'activerecord-bogacs', '~> 0.3'
34
+ s.add_dependency 'activerecord-jdbc-adapter', '~> 1.3'
35
+ # Required to avoid errors like java.lang.NoClassDefFoundError: org/bouncycastle/asn1/DERBoolean
36
+ s.add_dependency 'jruby-openssl', '~> 0.9.6'
37
+ end
38
+ s.add_dependency 'actionpack', '~> 4.1.0'
39
+ s.add_dependency 'actionview', '~> 4.1.0'
40
+ s.add_dependency 'monetize', '~> 1.1.0'
41
+ s.add_dependency 'money', '~> 6.5.1'
42
+
43
+ s.add_development_dependency 'jbundler', '~> 0.9.2'
44
+ s.add_development_dependency 'rake', '>= 10.0.0'
45
+ s.add_development_dependency 'rspec', '~> 2.12.0'
46
+ if defined?(JRUBY_VERSION)
47
+ s.add_development_dependency 'jdbc-sqlite3', '~> 3.7'
48
+ s.add_development_dependency 'jdbc-mariadb', '~> 1.1'
49
+ else
50
+ s.add_development_dependency 'sqlite3', '~> 1.3.7'
51
+ end
52
+ end
@@ -0,0 +1,3 @@
1
+ mainClass=Killbill::Chartmogul::ChartmogulPlugin
2
+ require=chart_mogul
3
+ pluginType=NOTIFICATION
@@ -0,0 +1,42 @@
1
+ require 'sinatra'
2
+
3
+ require 'killbill'
4
+
5
+ require 'chart_mogul/updater'
6
+ require 'chart_mogul/updater_initializer'
7
+
8
+ module Killbill::Chartmogul
9
+ class ChartmogulPlugin < Killbill::Plugin::Notification
10
+
11
+ # For testing
12
+ attr_reader :initializer
13
+
14
+ def initialize
15
+ super
16
+
17
+ @config_key_name = 'PLUGIN_CONFIG_killbill-chartmogul'.to_sym
18
+ end
19
+
20
+ def start_plugin
21
+ @logger.progname = 'chartmogul-plugin'
22
+
23
+ super
24
+
25
+ @initializer = Killbill::Chartmogul::UpdaterInitializer.instance
26
+ @initializer.initialize!(@config_key_name, "#{@conf_dir}/chartmogul.yml", @kb_apis, @logger)
27
+ end
28
+
29
+ def on_event(event)
30
+ if (event.event_type == :TENANT_CONFIG_CHANGE || event.event_type == :TENANT_CONFIG_DELETION) &&
31
+ event.meta_data.to_sym == @config_key_name
32
+ @logger.info("Invalidating plugin key='#{@config_key_name}', tenant='#{event.tenant_id}'")
33
+ @initializer.recycle_updater(event.tenant_id)
34
+ elsif !@initializer.nil?
35
+ updater = @initializer.updater(event.tenant_id)
36
+ updater.update(event.event_type, event.object_id, event.account_id, event.tenant_id) unless updater.nil?
37
+ else
38
+ @logger.warn "ChartMogul wasn't started properly - check logs"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ configure do
2
+ # Usage: rackup -Ilib -E test
3
+ if development? or test?
4
+ require 'logger'
5
+ Killbill::Chartmogul::UpdaterInitializer.instance.initialize! 'PLUGIN_CONFIG_killbill-chartmogul'.to_sym,
6
+ nil,
7
+ Logger.new(STDOUT)
8
+ end
9
+ end
@@ -0,0 +1,248 @@
1
+ # BUG NoMethodError: undefined method `prepend' for ChartMogul::Metrics::ARPAs:Class
2
+ module ChartMogul
3
+ class Object
4
+ end
5
+
6
+ class APIResource < Object
7
+ def self.prepend(*args)
8
+ end
9
+ end
10
+ end
11
+
12
+ require 'chartmogul'
13
+
14
+ # Enable some logging...
15
+ module ChartMogul
16
+ class APIResource
17
+ def self.connection
18
+ @connection ||= Faraday.new(url: ChartMogul::API_BASE) do |faraday|
19
+ faraday.use Faraday::Request::BasicAuthentication, ChartMogul.account_token, ChartMogul.secret_key
20
+ faraday.use Faraday::Response::RaiseError
21
+ faraday.use Faraday::Adapter::NetHttp
22
+
23
+ faraday.response :logger
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ require 'monetize'
30
+ require 'thread_safe'
31
+
32
+ module Killbill::Chartmogul
33
+ class Updater
34
+
35
+ def initialize(config, kb_apis, logger)
36
+ # TODO Won't work well in multi-tenant mode
37
+ ::ChartMogul.account_token = config[:account_token]
38
+ ::ChartMogul.secret_key = config[:secret_key]
39
+
40
+ @kb_apis = kb_apis
41
+ @logger = logger
42
+
43
+ @plan_cache = ThreadSafe::Cache.new
44
+ end
45
+
46
+ def update(event_type, object_id, account_id, tenant_id)
47
+ # Listen to invoice payment events
48
+ if event_type == :INVOICE_PAYMENT_SUCCESS || event_type == :INVOICE_PAYMENT_FAILED
49
+ first_or_create_invoice(object_id, account_id, tenant_id)
50
+ end
51
+
52
+ # TODO future work:
53
+ # * Subscription events (cancellation)
54
+ # * Refund events
55
+ # * Tags and custom fields events
56
+ end
57
+
58
+ # For testing
59
+ #private
60
+
61
+ # TODO Should probably be configurable on a per tenant basis
62
+ def first_or_create_datasource(name = 'killbill')
63
+ sources = ::ChartMogul::Import::DataSource.all
64
+ ds = sources.find { |ds| ds.name == name }
65
+
66
+ ds || ::ChartMogul::Import::DataSource.create!(:name => name)
67
+ end
68
+
69
+ def first_or_create_customer(kb_account, ds = first_or_create_datasource)
70
+ customer = ::ChartMogul::Import::Customer.all(:page => 1,
71
+ :per_page => 1,
72
+ :data_source_uuid => ds.uuid,
73
+ :external_id => kb_account.external_key).first
74
+
75
+ customer || ::ChartMogul::Import::Customer.create!(:data_source_uuid => ds.uuid,
76
+ :external_id => kb_account.external_key,
77
+ :name => kb_account.name,
78
+ :email => kb_account.email,
79
+ :company => kb_account.company_name,
80
+ :country => kb_account.country,
81
+ :state => kb_account.state_or_province,
82
+ :city => kb_account.city,
83
+ :zip => kb_account.postal_code)
84
+ end
85
+
86
+ def first_or_create_plan(kb_plan_phase, ds = first_or_create_datasource)
87
+ return @plan_cache[kb_plan_phase.name] unless @plan_cache[kb_plan_phase.name].nil?
88
+
89
+ if kb_plan_phase.phase_type != :EVERGREEN
90
+ # TODO It doesn't look like there is support for trials, etc.
91
+ return nil
92
+ end
93
+
94
+ if kb_plan_phase.recurring.nil?
95
+ # TODO It doesn't look like there is support for non-recurring phases
96
+ return nil
97
+ end
98
+
99
+ interval_count = nil
100
+ interval_unit = nil
101
+ case kb_plan_phase.recurring.billing_period
102
+ when :DAILY
103
+ interval_count = 1
104
+ interval_unit = 'day'
105
+ when :WEEKLY
106
+ interval_count = 7
107
+ interval_unit = 'day'
108
+ when :BIWEEKLY
109
+ interval_count = 14
110
+ interval_unit = 'day'
111
+ when :THIRTY_DAYS
112
+ interval_count = 30
113
+ interval_unit = 'day'
114
+ when :MONTHLY
115
+ interval_count = 1
116
+ interval_unit = 'month'
117
+ when :QUARTERLY
118
+ interval_count = 3
119
+ interval_unit = 'month'
120
+ when :BIANNUAL
121
+ interval_count = 6
122
+ interval_unit = 'month'
123
+ when :ANNUAL
124
+ interval_count = 1
125
+ interval_unit = 'year'
126
+ when :BIENNIAL
127
+ interval_count = 2
128
+ interval_unit = 'year'
129
+ else
130
+ return nil
131
+ end
132
+
133
+ plan = ::ChartMogul::Import::Plan.all(:page => 1,
134
+ :per_page => 1,
135
+ :data_source_uuid => ds.uuid,
136
+ :external_id => kb_plan_phase.name).first
137
+
138
+ @plan_cache[kb_plan_phase.name] = plan || ::ChartMogul::Import::Plan.create!(:data_source_uuid => ds.uuid,
139
+ :external_id => kb_plan_phase.name,
140
+ :name => kb_plan_phase.name,
141
+ :interval_count => interval_count,
142
+ :interval_unit => interval_unit)
143
+ @plan_cache[kb_plan_phase.name]
144
+ end
145
+
146
+ def first_or_create_invoice(kb_invoice_id, kb_account_id, kb_tenant_id, ds = first_or_create_datasource)
147
+ kb_context = @kb_apis.create_context(kb_tenant_id)
148
+ # Note that the get_invoice API won't populate the payments
149
+ kb_invoices = @kb_apis.invoice_user_api.get_invoices_by_account(kb_account_id, true, kb_context)
150
+ kb_invoice = kb_invoices.find { |kb_invoice| kb_invoice.id == kb_invoice_id }
151
+ return if kb_invoice.nil?
152
+
153
+ kb_account = @kb_apis.account_user_api.get_account_by_id(kb_invoice.account_id, kb_context)
154
+ customer = first_or_create_customer(kb_account, ds)
155
+
156
+ # TODO There is no support for adjustments
157
+ invoices = customer.invoices
158
+ return if invoices.find { |invoice| invoice.external_id == kb_invoice.id }
159
+
160
+ line_items = []
161
+ transactions = []
162
+
163
+ kb_invoice.invoice_items.each do |kb_invoice_item|
164
+ amount_in_cents = kb_invoice_item.amount.nil? ? nil : ::Monetize.from_numeric(kb_invoice_item.amount, kb_invoice_item.currency).cents.to_i
165
+
166
+ if kb_invoice_item.invoice_item_type == :RECURRING
167
+ kb_static_catalog = @kb_apis.catalog_user_api.get_current_catalog('unused', kb_context)
168
+ kb_plan_phase = find_current_phase(kb_invoice_item.phase_name, kb_static_catalog)
169
+ plan = kb_plan_phase.nil? ? nil : first_or_create_plan(kb_plan_phase, ds)
170
+ next if plan.nil?
171
+
172
+ rate_in_cents = kb_invoice_item.rate.nil? ? nil : ::Monetize.from_numeric(kb_invoice_item.rate, kb_invoice_item.currency).cents.to_i
173
+ prorated = amount_in_cents != rate_in_cents
174
+
175
+ line_items << ::ChartMogul::Import::LineItems::Subscription.new(:external_id => kb_invoice_item.id,
176
+ :plan_uuid => plan.uuid,
177
+ :subscription_external_id => kb_invoice_item.subscription_id,
178
+ :service_period_start => kb_invoice_item.start_date,
179
+ :service_period_end => kb_invoice_item.end_date,
180
+ :amount_in_cents => amount_in_cents,
181
+ :prorated => prorated,
182
+ :quantity => 1,
183
+ :discount_code => nil,
184
+ :discount_amount_in_cents => nil,
185
+ # TODO
186
+ :tax_amount_in_cents => nil)
187
+ elsif kb_invoice_item.invoice_item_type == :FIXED || kb_invoice_item.invoice_item_type == :EXTERNAL_CHARGE
188
+ line_items << ::ChartMogul::Import::LineItems::OneTime.new(:external_id => kb_invoice_item.id,
189
+ :description => kb_invoice_item.description,
190
+ :amount_in_cents => amount_in_cents,
191
+ :quantity => 1,
192
+ :discount_code => nil,
193
+ :discount_amount_in_cents => nil,
194
+ # TODO
195
+ :tax_amount_in_cents => nil)
196
+ else
197
+ # TODO It seems that other types aren't supported
198
+ end
199
+ end
200
+
201
+ kb_invoice.payments.each do |kb_invoice_payment|
202
+ if kb_invoice_payment.type == :ATTEMPT
203
+ transactions << ::ChartMogul::Import::Transactions::Payment.new(:external_id => kb_invoice_payment.id,
204
+ :date => kb_invoice_payment.payment_date,
205
+ :result => kb_invoice_payment.is_success)
206
+ elsif kb_invoice_payment.type == :REFUND
207
+ transactions << ::ChartMogul::Import::Transactions::Refund.new(:external_id => kb_invoice_payment.id,
208
+ :date => kb_invoice_payment.payment_date,
209
+ :result => kb_invoice_payment.is_success)
210
+ else
211
+ # TODO There is no support for chargebacks
212
+ end
213
+ end
214
+
215
+ return if line_items.empty?
216
+
217
+ invoice = ::ChartMogul::Import::Invoice.new(:external_id => kb_invoice.id,
218
+ :date => kb_invoice.invoice_date,
219
+ :currency => kb_invoice.currency.to_s,
220
+ :due_date => kb_invoice.invoice_date,
221
+ :line_items => line_items,
222
+ :transactions => transactions)
223
+
224
+ begin
225
+ ::ChartMogul::Import::CustomerInvoices.create!(:customer_uuid => customer.uuid,
226
+ :invoices => [invoice])
227
+ rescue => e
228
+ @logger.warn "Unable to create invoice #{invoice.inspect}: #{e.message}\n#{e.backtrace.join("\n")}"
229
+ end
230
+ end
231
+
232
+ def find_current_phase(phase_name, catalog)
233
+ plan_name = nil
234
+ %w(trial discount fixedterm evergreen).each do |type|
235
+ if phase_name.end_with?(type)
236
+ plan_name = phase_name[0..phase_name.size - type.size - 2]
237
+ break
238
+ end
239
+ end
240
+ return nil if plan_name.nil?
241
+
242
+ kb_plan = catalog.current_plans.find { |plan| plan.name == plan_name }
243
+ return nil if kb_plan.nil?
244
+
245
+ kb_plan.all_phases.find { |phase| phase.name == phase_name }
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,60 @@
1
+ require 'active_support/core_ext/string'
2
+ require 'erb'
3
+ require 'pathname'
4
+ require 'thread_safe'
5
+
6
+ module Killbill::Chartmogul
7
+ class UpdaterInitializer
8
+ include Singleton
9
+
10
+ def initialize!(config_key_name, config_file, kb_apis, logger)
11
+ @config_key_name = config_key_name
12
+ @kb_apis = kb_apis
13
+ @logger = logger
14
+
15
+ @per_tenant_config_cache = ThreadSafe::Cache.new
16
+
17
+ # Look for global config
18
+ if !config_file.blank? && Pathname.new(config_file).file?
19
+ path = Pathname.new(config_file).expand_path
20
+ @glob_config = YAML.load(ERB.new(File.read(path.to_s)).result)
21
+ else
22
+ @glob_config = {}
23
+ end
24
+ end
25
+
26
+ def recycle_updater(kb_tenant_id)
27
+ @per_tenant_config_cache[kb_tenant_id] = nil
28
+ end
29
+
30
+ def updater(kb_tenant_id)
31
+ config = get_tenant_config(kb_tenant_id)
32
+ if config.nil?
33
+ @logger.warn "ChartMogul wasn't configured properly for kbTenantId='#{kb_tenant_id}'"
34
+ return nil
35
+ else
36
+ ::Killbill::Chartmogul::Updater.new(config[:chartmogul], @kb_apis, @logger)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def get_tenant_config(kb_tenant_id)
43
+ if @per_tenant_config_cache[kb_tenant_id].nil?
44
+ # Make the api api to verify if there is a per tenant value
45
+ context = @kb_apis.create_context(kb_tenant_id) if kb_tenant_id
46
+ values = @kb_apis.tenant_user_api.get_tenant_values_for_key(@config_key_name, context) if context
47
+ # If we have a per tenant value, insert it into the cache
48
+ if values && values[0]
49
+ parsed_config = YAML.load(values[0])
50
+ @per_tenant_config_cache[kb_tenant_id] = parsed_config
51
+ else
52
+ # Otherwise, add global config so we don't have to make the tenant call on each operation
53
+ @per_tenant_config_cache[kb_tenant_id] = @glob_config
54
+ end
55
+ end
56
+ # Return value from cache in any case
57
+ @per_tenant_config_cache[kb_tenant_id]
58
+ end
59
+ end
60
+ end