bunny_app 2.3.0 → 2.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f14177f1f9c2cff9cfe3ee28ac8bba3e22bbcf2dab081c9520a3355e284bf45
4
- data.tar.gz: 658782618952e5b80d66fec671c8cdbd79cc9375c6f58cb3ab2ea43c468985df
3
+ metadata.gz: a804e6c2ed27d42bce0095ca83a626ea784344eb6a7fa0533ebedaa29c0e83f6
4
+ data.tar.gz: 738ea16a8669e5dd954d39cf51ff9cad4b3f16ee8776b13bc0519ca35f901582
5
5
  SHA512:
6
- metadata.gz: ce7d03e473bda729c45611e529afafd01e0db24afde29e132e51e2d2db78f8241158f463522d115a90746479f3af20458b65506e5edb4c4411bf90285a6498c5
7
- data.tar.gz: 3963e0e0603b0a4091ecee8504f4967d70b1662cd68213c6709f415cafa3bd6c465aebfb6508a7a3f206aa07193e5f789f03bdce3b0d60582bc9ec55c0e362db
6
+ metadata.gz: a9f8de180c86a45db4ea12ca23cefc957e2be83b464af1c6aa7e80c32bb5a18b7a1071e9a496c7270757cd7c6c1c5293ae80497446f93935d1d14d2bfeebeeb7
7
+ data.tar.gz: 9d70f3ec0074f403c6dae3790818fce2484cdd29cf775dea7d057de13adccddbe7947242b38307872c1766ab673c6e476775bee1472f68a0e949be836157b745
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0 (14-Apr-2026)
4
+
5
+ - Add `Subscription.quantity_update` for updating subscription charge quantities
6
+ - Add `Subscription.trial_convert` for converting a trial to a paid subscription
7
+
8
+ ## 2.3.0 (22-Nov-2025)
9
+
10
+ - Remove deprecated `latestProvisioningChange` fields from `Tenant.find_by` query
11
+
3
12
  ## 2.2.2 (13-Sep-2025)
4
13
 
5
14
  - Make returnUrl optional in PortalSession.create mutation
data/README.md CHANGED
@@ -1,182 +1,375 @@
1
1
  # bunny-ruby
2
2
 
3
- Ruby SDK for Bunny
3
+ Ruby SDK for [Bunny](https://www.bunny.com) — the subscription billing and management platform.
4
4
 
5
5
  ## Installation
6
6
 
7
- Run `bundle add bunny_app` or add this line to your application's Gemfile:
7
+ Add to your Gemfile:
8
8
 
9
9
  ```ruby
10
10
  gem 'bunny_app'
11
11
  ```
12
12
 
13
- And then execute:
13
+ Then run:
14
14
 
15
15
  ```sh
16
- $ bundle
16
+ bundle install
17
17
  ```
18
18
 
19
- Or install it yourself as:
19
+ Or install directly:
20
20
 
21
21
  ```sh
22
- $ gem install bunny_app
22
+ gem install bunny_app
23
23
  ```
24
24
 
25
- ## Getting Started
25
+ ## Configuration
26
26
 
27
- You can use this gem to send customized graphql queries to Bunny or use the built in convience methods.
27
+ ### With client credentials (recommended)
28
28
 
29
- First configure the Bunny client.
29
+ Using `client_id` and `client_secret` enables automatic token refresh when the access token expires.
30
30
 
31
31
  ```ruby
32
32
  require 'bunny_app'
33
33
 
34
- # We recommend using the client_id/secret of your Bunny client app
35
- # this will enable automatic retries when the access token expires
36
34
  BunnyApp.config do |c|
37
- c.client_id = 'xxx'
38
- c.client_secret = 'xxx'
39
- c.scope = 'standard:read standard:write'
40
- c.base_uri = 'https://<subdomain>.bunny.com'
35
+ c.client_id = 'your-client-id'
36
+ c.client_secret = 'your-client-secret'
37
+ c.scope = 'standard:read standard:write'
38
+ c.base_uri = 'https://<subdomain>.bunny.com'
41
39
  end
40
+ ```
41
+
42
+ ### With a pre-existing access token
42
43
 
43
- # Alternately you can generate the access token outside of this sdk
44
- # be aware that if your access_token expires an exception will be raised
44
+ If you manage token lifecycle yourself, you can pass the token directly. An `AuthorizationError` will be raised if the token expires.
45
+
46
+ ```ruby
45
47
  BunnyApp.config do |c|
46
- c.access_token = 'xxx'
47
- c.base_uri = 'https://<subdomain>.bunny.com'
48
+ c.access_token = 'your-access-token'
49
+ c.base_uri = 'https://<subdomain>.bunny.com'
48
50
  end
49
51
  ```
50
52
 
51
- > Remember! Don't commit secrets to source control!
53
+ > **Never commit secrets to source control.** Load credentials from environment variables or a secret store.
52
54
 
53
- ### Generate rails config
55
+ ### Rails initializer
54
56
 
55
- Create a config file at `config/initializers/bunny_app.rb`
57
+ Generate a config file at `config/initializers/bunny_app.rb`:
56
58
 
57
59
  ```sh
58
- > bin/rails g bunny_app:install
60
+ bin/rails g bunny_app:install
59
61
  ```
60
62
 
63
+ This creates a template that reads credentials from environment variables:
64
+
65
+ ```ruby
66
+ BunnyApp.config do |c|
67
+ c.client_id = ENV['BUNNY_APP_CLIENT_ID']
68
+ c.client_secret = ENV['BUNNY_APP_CLIENT_SECRET']
69
+ c.scope = ENV['BUNNY_APP_SCOPE']
70
+ c.base_uri = 'https://<subdomain>.bunny.com'
71
+ end
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Subscriptions
77
+
61
78
  ### Create a subscription
62
79
 
80
+ Create a new subscription for an account. You can create the account inline or attach to an existing account by ID.
81
+
63
82
  ```ruby
64
- response = BunnyApp::Subscription.create(
83
+ # Create with a new account
84
+ subscription = BunnyApp::Subscription.create(
65
85
  price_list_code: 'starter',
66
86
  options: {
67
- account_name: "Superdesk",
68
- first_name: "Meg",
69
- last_name: "La Don",
70
- email: "meg@example.com",
71
- trial: true,
72
- tenant_code: "123456",
73
- tenant_name: "Superdesk"
87
+ account_name: 'Acme Corp',
88
+ first_name: 'Jane',
89
+ last_name: 'Smith',
90
+ email: 'jane@acme.com',
91
+ trial: true,
92
+ tenant_code: 'acme-123',
93
+ tenant_name: 'Acme Corp'
94
+ }
95
+ )
96
+
97
+ # Create against an existing account
98
+ subscription = BunnyApp::Subscription.create(
99
+ price_list_code: 'starter',
100
+ options: {
101
+ account_id: '456',
102
+ tenant_code: 'acme-123',
103
+ tenant_name: 'Acme Corp'
74
104
  }
75
105
  )
76
106
  ```
77
107
 
78
- ### Get a portal session token for use with Bunny.js
108
+ Returns a hash containing the created subscription, including `id`, `state`, `trialStartDate`, `trialEndDate`, `plan`, `priceList`, and `tenant`.
109
+
110
+ ### Update subscription quantities
111
+
112
+ Adjust the quantities for one or more charges on an existing subscription.
79
113
 
80
114
  ```ruby
81
- response = BunnyApp::PortalSession.create(
82
- tenant_code: "123456"
115
+ quote = BunnyApp::Subscription.quantity_update(
116
+ subscription_id: '456123',
117
+ quantities: [
118
+ { code: 'users', quantity: 25 }
119
+ ],
120
+ options: {
121
+ invoice_immediately: true,
122
+ start_date: '2024-06-01',
123
+ name: 'Add users — June',
124
+ allow_quantity_limits_override: false
125
+ }
83
126
  )
84
127
  ```
85
128
 
86
- ### Track feature usage
129
+ Returns a `quote` hash with `id` and `name`.
87
130
 
88
- If you have usage based billing or just want to track feature usage then use this method.
131
+ ### Convert a trial to paid
89
132
 
90
133
  ```ruby
91
- # Usage is tracked as if it just happened
92
- response = BunnyApp::FeatureUsage.create(
93
- quantity: 5, feature_code: 'products', tenant_code: '2')
134
+ # Convert using a price list code
135
+ result = BunnyApp::Subscription.trial_convert(
136
+ subscription_id: '456123',
137
+ price_list_code: 'starter'
138
+ )
94
139
 
95
- # Usage is tracked using the date supplied
96
- response = BunnyApp::FeatureUsage.create(
97
- quantity: 5, feature_code: 'products', tenant_code: '2', usage_at: '2022-03-10')
140
+ # Convert using a price list ID and payment method
141
+ result = BunnyApp::Subscription.trial_convert(
142
+ subscription_id: '456123',
143
+ price_list_id: '789',
144
+ payment_id: '101112'
145
+ )
98
146
  ```
99
147
 
100
- ### Custom queries & mutations
148
+ Returns a hash containing `invoice` (with `amount`, `id`, `number`, `subtotal`) and `subscription` (with `id`, `name`).
101
149
 
102
- Alternately you can build and send your own custom graphql queries or mutations
150
+ ### Cancel a subscription
103
151
 
104
152
  ```ruby
105
- query = <<-'GRAPHQL'
106
- mutation featureUsageCreate ($attributes: FeatureUsageAttributes!) {
107
- featureUsageCreate (attributes: $attributes) {
108
- errors
109
- featureUsage {
110
- id
111
- quantity
112
- usageAt
113
- tenant {
114
- id
115
- code
116
- name
117
- }
118
- feature {
119
- id
120
- code
121
- name
122
- }
123
- }
124
- }
125
- }
126
- GRAPHQL
153
+ BunnyApp::Subscription.cancel(subscription_id: '456123')
154
+ # => true
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Tenants
160
+
161
+ ### Create a tenant
162
+
163
+ ```ruby
164
+ tenant = BunnyApp::Tenant.create(
165
+ name: 'Acme Corp',
166
+ code: 'acme-123',
167
+ account_id: '456',
168
+ platform_code: 'main' # optional, defaults to 'main'
169
+ )
170
+ ```
171
+
172
+ Returns a hash with `id`, `code`, `name`, and `platform`.
173
+
174
+ ### Find a tenant by code
175
+
176
+ ```ruby
177
+ tenant = BunnyApp::Tenant.find_by(code: 'acme-123')
178
+ # => { "id" => "1", "code" => "acme-123", "name" => "Acme Corp",
179
+ # "subdomain" => "acme", "account" => { "id" => "456", ... } }
180
+ ```
127
181
 
128
- variables = {
129
- attributes: {
130
- quantity: 1,
131
- usageAt: "2022-03-10",
132
- tenantCode: "123456",
133
- featureCode: "users"
182
+ Returns `nil` if no tenant is found.
183
+
184
+ ---
185
+
186
+ ## Tenant Metrics
187
+
188
+ Push usage and engagement metrics to Bunny for a tenant. Useful for health scoring and churn signals.
189
+
190
+ ```ruby
191
+ BunnyApp::TenantMetrics.update(
192
+ code: 'acme-123',
193
+ last_login: '2024-06-01T12:00:00Z',
194
+ user_count: 42,
195
+ utilization_metrics: {
196
+ projects_created: 10,
197
+ exports_run: 3
134
198
  }
135
- }
199
+ )
200
+ # => true
201
+ ```
202
+
203
+ `utilization_metrics` is optional and accepts any key/value pairs you want to track.
204
+
205
+ ---
136
206
 
137
- response = BunnyApp.query(query, variables)
207
+ ## Feature Usage
208
+
209
+ Track usage of individual features for usage-based billing or analytics.
210
+
211
+ ```ruby
212
+ # Track usage now
213
+ usage = BunnyApp::FeatureUsage.create(
214
+ quantity: 5,
215
+ feature_code: 'api_calls',
216
+ subscription_id: '456123'
217
+ )
218
+
219
+ # Track usage for a specific date
220
+ usage = BunnyApp::FeatureUsage.create(
221
+ quantity: 5,
222
+ feature_code: 'api_calls',
223
+ subscription_id: '456123',
224
+ usage_at: '2024-03-10'
225
+ )
138
226
  ```
139
227
 
140
- ### Verify webhook signature
228
+ Returns a hash with `id`, `quantity`, `usageAt`, `subscription`, and `feature`.
229
+
230
+ ---
141
231
 
142
- Bunny can send webhooks for key actions like a subscription change. When you get a webhook from Bunny you should verify the signature that is supplied in the `x-bunny-signature` header matches the payload.
232
+ ## Portal Sessions
233
+
234
+ Generate a short-lived token to embed the Bunny billing portal in your app using Bunny.js.
143
235
 
144
236
  ```ruby
145
- payload = '{"type":"SubscriptionProvisioningChange","payload":{"subscription":{"id":27,"state":"trial","trial_start_date":"2022-06-04","trial_end_date":"2022-06-18","start_date":null,"end_date":null,"auto_renew":false,"account":{"id":33,"name":"Ondricka, Flatley and Kessler"},"tenant":null,"product":{"code":"stealth","name":"Stealth","description":null,"sku":null},"features":[{"code":"users","quantity":1},{"code":"crm","quantity":null}]}}}'
237
+ # Basic session
238
+ token = BunnyApp::PortalSession.create(tenant_code: 'acme-123')
239
+
240
+ # With a return URL and custom expiry (in hours, default: 24)
241
+ token = BunnyApp::PortalSession.create(
242
+ tenant_code: 'acme-123',
243
+ return_url: 'https://yourapp.com/billing',
244
+ expiry_hours: 4
245
+ )
246
+ ```
146
247
 
147
- BunnyApp::Webhook.verify("8bd5aa9c6a96fbce9fc3065af6e9871ac19a1d0a", payload, "secret-key")
248
+ Returns the session token string.
249
+
250
+ ---
251
+
252
+ ## Platforms
253
+
254
+ Platforms allow you to group tenants. Create a platform before assigning tenants to it.
255
+
256
+ ```ruby
257
+ platform = BunnyApp::Platform.create(
258
+ name: 'My SaaS Platform',
259
+ code: 'my-saas'
260
+ )
261
+ # => { "id" => "1", "name" => "My SaaS Platform", "code" => "my-saas" }
148
262
  ```
149
263
 
150
- ## Requirements
264
+ ---
151
265
 
152
- This gem requires Ruby 2.5+
266
+ ## Webhooks
153
267
 
154
- ## Development
268
+ Bunny sends webhooks for events like subscription state changes. Verify the `x-bunny-signature` header to confirm the payload is authentic.
155
269
 
156
- Run `bundle install` to install dependencies.
270
+ ```ruby
271
+ payload = request.raw_post
272
+ signature = request.headers['x-bunny-signature']
273
+ signing_key = ENV['BUNNY_WEBHOOK_SECRET']
274
+
275
+ if BunnyApp::Webhook.verify(signature, payload, signing_key)
276
+ # payload is authentic — process the event
277
+ event = JSON.parse(payload)
278
+ case event['type']
279
+ when 'SubscriptionProvisioningChange'
280
+ # handle provisioning change
281
+ end
282
+ else
283
+ head :unauthorized
284
+ end
285
+ ```
157
286
 
158
- Run `bundle exec rake spec` to run the tests.
287
+ ---
159
288
 
160
- You can also run `bin/console` for an interactive prompt that will allow you to experiment.
289
+ ## Custom GraphQL Queries
161
290
 
162
- Set IGNORE_SSL when running locally to ignore ssl warnings.
291
+ You can send any GraphQL query or mutation directly if you need fields not covered by the convenience methods.
163
292
 
164
- ```sh
165
- > IGNORE_SSL=true bin/console
293
+ ### Synchronous query
294
+
295
+ ```ruby
296
+ query = <<~GRAPHQL
297
+ query GetTenant($code: String!) {
298
+ tenant(code: $code) {
299
+ id
300
+ name
301
+ account {
302
+ id
303
+ name
304
+ }
305
+ }
306
+ }
307
+ GRAPHQL
308
+
309
+ response = BunnyApp.query(query, { code: 'acme-123' })
310
+ tenant = response['data']['tenant']
166
311
  ```
167
312
 
168
- ## Publish to Ruby gems
313
+ ### Asynchronous query (fire-and-forget)
169
314
 
170
- Update `version.rb` with a new version number then build the gem
315
+ Runs the request in a background thread. Useful for non-critical tracking calls where you don't want to block the request cycle.
316
+
317
+ ```ruby
318
+ BunnyApp.query_async(query, variables)
319
+ ```
320
+
321
+ ---
322
+
323
+ ## Error Handling
324
+
325
+ All convenience methods raise on error. Two exception classes are provided:
326
+
327
+ | Exception | When raised |
328
+ |---|---|
329
+ | `BunnyApp::AuthorizationError` | Invalid or expired credentials |
330
+ | `BunnyApp::ResponseError` | API returned errors in the response body |
331
+
332
+ ```ruby
333
+ begin
334
+ BunnyApp::Subscription.cancel(subscription_id: '456123')
335
+ rescue BunnyApp::AuthorizationError => e
336
+ # Re-authenticate or alert
337
+ Rails.logger.error "Bunny auth failed: #{e.message}"
338
+ rescue BunnyApp::ResponseError => e
339
+ # The API rejected the request
340
+ Rails.logger.error "Bunny error: #{e.message}"
341
+ end
342
+ ```
343
+
344
+ ---
345
+
346
+ ## Requirements
347
+
348
+ Ruby 2.5+
349
+
350
+ ---
351
+
352
+ ## Development
171
353
 
172
354
  ```sh
173
- gem build
355
+ bundle install # install dependencies
356
+ bundle exec rake spec # run tests
357
+ bin/console # interactive console
174
358
  ```
175
359
 
176
- Then publish using the new build number
360
+ Set `IGNORE_SSL=true` when running locally to suppress SSL warnings:
177
361
 
178
362
  ```sh
363
+ IGNORE_SSL=true bin/console
364
+ ```
365
+
366
+ ## Publishing
367
+
368
+ Update `lib/bunny_app/version.rb`, then:
369
+
370
+ ```sh
371
+ gem build
179
372
  gem push bunny_app-x.x.x.gem
180
373
  ```
181
374
 
182
- The rubygems account for publishing is protected by MFA and currently managed by @richet
375
+ The RubyGems account is protected by MFA and managed by @richet.
@@ -39,6 +39,36 @@ module BunnyApp
39
39
  }
40
40
  GRAPHQL
41
41
 
42
+ @subscription_quantity_update_mutation = <<-GRAPHQL
43
+ mutation subscriptionQuantityUpdate ($subscriptionId: ID!, $quantities: [SubscriptionChargeQuantityAttributes!]!, $invoiceImmediately: Boolean!, $startDate: ISO8601Date!, $name: String!, $allowQuantityLimitsOverride: Boolean!) {
44
+ subscriptionQuantityUpdate (subscriptionId: $subscriptionId, quantities: $quantities, invoiceImmediately: $invoiceImmediately, startDate: $startDate, name: $name, allowQuantityLimitsOverride: $allowQuantityLimitsOverride) {
45
+ errors
46
+ quote {
47
+ id
48
+ name
49
+ }
50
+ }
51
+ }
52
+ GRAPHQL
53
+
54
+ @subscription_trial_convert_mutation = <<-GRAPHQL
55
+ mutation subscriptionTrialConvert ($subscriptionId: ID!, $paymentId: ID!, $priceListId: ID!, $priceListCode: String!) {
56
+ subscriptionTrialConvert (subscriptionId: $subscriptionId, paymentId: $paymentId, priceListId: $priceListId, priceListCode: $priceListCode) {
57
+ errors
58
+ invoice {
59
+ amount
60
+ id
61
+ number
62
+ subtotal
63
+ }
64
+ subscription {
65
+ id
66
+ name
67
+ }
68
+ }
69
+ }
70
+ GRAPHQL
71
+
42
72
  @subscription_cancel_mutation = <<-GRAPHQL
43
73
  mutation subscriptionCancel ($ids: [ID!]!) {
44
74
  subscriptionCancel (ids: $ids) {
@@ -83,6 +113,43 @@ module BunnyApp
83
113
  res['data']['subscriptionCreate']['subscription']
84
114
  end
85
115
 
116
+ def self.quantity_update(subscription_id:, quantities:, options: {})
117
+ variables = {
118
+ subscriptionId: subscription_id,
119
+ quantities:,
120
+ invoiceImmediately: options[:invoice_immediately] || false,
121
+ startDate: options[:start_date],
122
+ name: options[:name],
123
+ allowQuantityLimitsOverride: options[:allow_quantity_limits_override] || false
124
+ }
125
+
126
+ res = Client.new.query(@subscription_quantity_update_mutation, variables)
127
+ if res['data']['subscriptionQuantityUpdate']['errors']
128
+ raise ResponseError,
129
+ res['data']['subscriptionQuantityUpdate']['errors'].join(',')
130
+ end
131
+
132
+ res['data']['subscriptionQuantityUpdate']['quote']
133
+ end
134
+
135
+ def self.trial_convert(subscription_id:, price_list_id: nil, price_list_code: nil, payment_id: nil)
136
+ variables = {
137
+ subscriptionId: subscription_id
138
+ }
139
+
140
+ variables[:paymentId] = payment_id if payment_id
141
+ variables[:priceListId] = price_list_id if price_list_id
142
+ variables[:priceListCode] = price_list_code if price_list_code
143
+
144
+ res = Client.new.query(@subscription_trial_convert_mutation, variables)
145
+ if res['data']['subscriptionTrialConvert']['errors']
146
+ raise ResponseError,
147
+ res['data']['subscriptionTrialConvert']['errors'].join(',')
148
+ end
149
+
150
+ res['data']['subscriptionTrialConvert']
151
+ end
152
+
86
153
  def self.cancel(subscription_id:)
87
154
  variables = {
88
155
  ids: [subscription_id]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BunnyApp
4
- VERSION = '2.3.0'
4
+ VERSION = '2.4.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bunny_app
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bunny
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-11-21 00:00:00.000000000 Z
12
+ date: 2026-04-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: httparty