killbill-chartmogul 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.
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