better_auth-stripe 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19c8cba9e5f67da8b742ec37f48ef758124d373812879ae8af0a49e950962fc5
4
- data.tar.gz: c848c8261f7376eb71d2c75d7e2a5fd3b3ef6ba384b92c2891e75de41be4a75b
3
+ metadata.gz: 48af5990d2d12e5e32d5393e5af619a69e0bb6a1f8c4c1d12c092eb75486279b
4
+ data.tar.gz: 3fcb40459ec56c1ec7dfaabfd9a4063b1399140b2c9e95704f332dc1acc2ce14
5
5
  SHA512:
6
- metadata.gz: 75dd173be79173cc0a40596f120b54f1160dfce41c98fa9ce2c60a9de13a91860e17b1f6d42840e798dbe44a9b8f50a3fdc31e8bc95c8c3b972955bd2c38c199
7
- data.tar.gz: be8037299688f25db5354ff51fdec3ba1925a0afa023116d3f054da294f4a8db16f1c2ceafb19c3e41c9e9110b00fa5cfa6cd7d617ce73243394df8ea927ba7a
6
+ metadata.gz: '019497cea0a84e06440b5405fd220f0da1507a500b4e42c95a7b31e8d8befd2e1636750232d6268b3a80a4302de84b5ee84695fd8e311cb32f4c6b6b80147565'
7
+ data.tar.gz: b5cd4a313e2480e6e2f7420242840dccf1a3081a2f535b123c07f6c55a20220e3313930463bb4221eb23dd7838bf062c673c1330a3e82af3d5b8f6b37ee92685
data/CHANGELOG.md CHANGED
@@ -2,4 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.1] - 2026-04-30
6
+
7
+ - Fixed Stripe checkout and subscription parity edge cases for reused customer IDs, plugin-owned schedule releases, missing checkout sessions, plan limits, and organization reference validation.
8
+ - Expanded Stripe organization and subscription parity coverage.
9
+
10
+ ## [0.2.0] - 2026-04-29
11
+
12
+ - Aligned Stripe subscription, checkout, portal, webhook, customer, and organization flows with upstream Better Auth behavior.
13
+ - Expanded Stripe documentation and tests for subscription lifecycle and organization billing parity.
14
+
15
+ ## [0.1.0] - 2026-04-28
16
+
5
17
  - Initial external Stripe package extracted from `better_auth`.
data/README.md CHANGED
@@ -19,14 +19,35 @@ auth = BetterAuth.auth(
19
19
  plugins: [
20
20
  BetterAuth::Plugins.stripe(
21
21
  stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
22
- stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET")
22
+ stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"),
23
+ subscription: {
24
+ enabled: true,
25
+ plans: [
26
+ { name: "pro", price_id: "price_monthly", annual_discount_price_id: "price_yearly" },
27
+ { name: "team", price_id: "price_team", seat_price_id: "price_team_seat" }
28
+ ]
29
+ }
23
30
  )
24
31
  ]
25
32
  )
26
33
  ```
27
34
 
35
+ ## Subscription Options
36
+
37
+ Set `subscription: { enabled: true, plans: [...] }` to enable checkout, portal, restore, list, and webhook subscription handling. Plans support `name`, `price_id`, `lookup_key`, `annual_discount_price_id`, `annual_discount_lookup_key`, `limits`, `free_trial`, `seat_price_id`, `line_items`, and `proration_behavior`.
38
+
39
+ Organization subscriptions require `organization: { enabled: true }` and an `authorize_reference` callback. When a plan has `seat_price_id`, organization member changes sync the Stripe seat item quantity.
40
+
28
41
  ## Notes
29
42
 
30
43
  This package depends on the official `stripe` gem. Keeping Stripe outside `better_auth` avoids installing Stripe SDK dependencies for applications that do not use billing.
31
44
 
32
45
  Pass `stripe_client:` when you need a custom Stripe client, Stripe Connect behavior, or a test double.
46
+
47
+ ## Subscriptions
48
+
49
+ Configure plans under `subscription: { enabled: true, plans: [...] }`. Ruby accepts upstream-equivalent plan keys including `price_id`, `annual_discount_price_id`, lookup-key variants, `limits`, `free_trial`, and `seat_price_id`.
50
+
51
+ For organization subscriptions, `seat_price_id` enables upstream-style seat billing. Checkout sends the base plan item with quantity `1` and a separate seat item whose quantity is the current organization member count. Webhooks read the seat item quantity back into the local `subscription.seats` field.
52
+
53
+ `scheduleAtPeriodEnd` / `schedule_at_period_end` on `/subscription/upgrade` creates a Stripe subscription schedule for active subscriptions, stores `stripeScheduleId`, and returns the configured `returnUrl` instead of opening the billing portal immediately.
@@ -6,7 +6,7 @@ require "stripe"
6
6
  module BetterAuth
7
7
  module Stripe
8
8
  class ClientAdapter
9
- attr_reader :customers, :checkout, :billing_portal, :subscriptions, :prices, :webhooks
9
+ attr_reader :customers, :checkout, :billing_portal, :subscriptions, :prices, :subscription_schedules, :webhooks
10
10
 
11
11
  def initialize(api_key)
12
12
  client = ::Stripe::StripeClient.new(api_key)
@@ -15,6 +15,7 @@ module BetterAuth
15
15
  @billing_portal = NamespaceAdapter.new(sessions: ResourceAdapter.new(client.v1.billing_portal.sessions))
16
16
  @subscriptions = ResourceAdapter.new(client.v1.subscriptions)
17
17
  @prices = ResourceAdapter.new(client.v1.prices)
18
+ @subscription_schedules = ResourceAdapter.new(client.v1.subscription_schedules)
18
19
  @webhooks = WebhooksAdapter.new
19
20
  end
20
21
  end
@@ -52,6 +53,10 @@ module BetterAuth
52
53
  def update(id, params = {})
53
54
  @resource.update(id, params || {})
54
55
  end
56
+
57
+ def release(id)
58
+ @resource.release(id)
59
+ end
55
60
  end
56
61
 
57
62
  class WebhooksAdapter
@@ -95,8 +100,10 @@ module BetterAuth
95
100
  "STRIPE_WEBHOOK_SECRET_NOT_FOUND" => "Stripe webhook secret not found",
96
101
  "FAILED_TO_CONSTRUCT_STRIPE_EVENT" => "Failed to construct Stripe event",
97
102
  "STRIPE_WEBHOOK_ERROR" => "Stripe webhook error",
103
+ "INVALID_CUSTOMER_TYPE" => "Customer type must be either user or organization",
98
104
  "INVALID_REQUEST_BODY" => "Invalid request body"
99
105
  }.freeze
106
+ STRIPE_UNSAFE_METADATA_KEYS = %w[__proto__ constructor prototype].freeze
100
107
 
101
108
  def stripe(options = {})
102
109
  config = normalize_hash(options)
@@ -135,6 +142,8 @@ module BetterAuth
135
142
  canceledAt: {type: "date", required: false},
136
143
  endedAt: {type: "date", required: false},
137
144
  seats: {type: "number", required: false},
145
+ billingInterval: {type: "string", required: false},
146
+ stripeScheduleId: {type: "string", required: false},
138
147
  limits: {type: "json", required: false}
139
148
  }
140
149
  }
@@ -172,6 +181,8 @@ module BetterAuth
172
181
  data["id"] ||= SecureRandom.hex(16)
173
182
  customer = stripe_find_or_create_user_customer(config, data, nil, hook_ctx)
174
183
  {data: {id: data["id"], stripeCustomerId: stripe_id(customer)}}
184
+ rescue
185
+ nil
175
186
  end
176
187
  },
177
188
  update: {
@@ -216,7 +227,10 @@ module BetterAuth
216
227
  !%w[canceled incomplete incomplete_expired].include?(stripe_fetch(subscription, "status").to_s)
217
228
  end
218
229
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION")) if active
219
- end
230
+ end,
231
+ after_add_member: ->(data, ctx) { stripe_sync_organization_seats(config, data, ctx) },
232
+ after_remove_member: ->(data, ctx) { stripe_sync_organization_seats(config, data, ctx) },
233
+ after_accept_invitation: ->(data, ctx) { stripe_sync_organization_seats(config, data, ctx) }
220
234
  }
221
235
  end
222
236
 
@@ -225,7 +239,7 @@ module BetterAuth
225
239
  session = Routes.current_session(ctx)
226
240
  body = normalize_hash(ctx.body)
227
241
  subscription_options = stripe_subscription_options(config)
228
- customer_type = (body[:customer_type] || "user").to_s
242
+ customer_type = stripe_customer_type!(body)
229
243
  reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
230
244
  stripe_authorize_reference!(ctx, session, reference_id, "upgrade-subscription", customer_type, subscription_options, explicit: body.key?(:reference_id))
231
245
 
@@ -243,13 +257,14 @@ module BetterAuth
243
257
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription_to_update && subscription_to_update["referenceId"] == reference_id
244
258
  end
245
259
 
260
+ subscriptions = subscription_to_update ? [subscription_to_update] : ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}])
261
+ reference_customer_id = subscriptions.find { |entry| entry["stripeCustomerId"] }&.fetch("stripeCustomerId", nil)
246
262
  customer_id = if customer_type == "organization"
247
- subscription_to_update&.fetch("stripeCustomerId", nil) || stripe_organization_customer(config, ctx, reference_id, body[:metadata])
263
+ subscription_to_update&.fetch("stripeCustomerId", nil) || reference_customer_id || stripe_organization_customer(config, ctx, reference_id, body[:metadata])
248
264
  else
249
- subscription_to_update&.fetch("stripeCustomerId", nil) || user["stripeCustomerId"] || stripe_create_customer(config, ctx, user, body[:metadata])
265
+ subscription_to_update&.fetch("stripeCustomerId", nil) || reference_customer_id || user["stripeCustomerId"] || stripe_create_customer(config, ctx, user, body[:metadata])
250
266
  end
251
267
 
252
- subscriptions = subscription_to_update ? [subscription_to_update] : ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}])
253
268
  active_or_trialing = subscriptions.find { |entry| stripe_active_or_trialing?(entry) }
254
269
  active_stripe_subscriptions = stripe_active_subscriptions(config, customer_id)
255
270
  active_stripe = active_stripe_subscriptions.find do |entry|
@@ -264,11 +279,16 @@ module BetterAuth
264
279
 
265
280
  price_id = stripe_price_id(config, plan, body[:annual])
266
281
  raise APIError.new("BAD_REQUEST", message: "Price ID not found for the selected plan") if price_id.to_s.empty?
282
+ auto_managed_seats = !!(plan[:seat_price_id] && customer_type == "organization")
283
+ member_count = auto_managed_seats ? ctx.context.adapter.count(model: "member", where: [{field: "organizationId", value: reference_id}]) : 0
284
+ requested_seats = auto_managed_seats ? member_count : (body[:seats] || 1)
285
+ seat_only_plan = auto_managed_seats && plan[:seat_price_id] == price_id
267
286
 
268
- active_stripe_item = stripe_subscription_item(active_stripe || {})
287
+ active_resolved = active_stripe && stripe_resolve_plan_item(config, active_stripe)
288
+ active_stripe_item = active_resolved&.fetch(:item, nil) || stripe_subscription_item(active_stripe || {})
269
289
  stripe_price_id_value = stripe_fetch(stripe_fetch(active_stripe_item || {}, "price") || {}, "id")
270
290
  same_plan = active_or_trialing && active_or_trialing["plan"].to_s.downcase == body[:plan].to_s.downcase
271
- same_seats = active_or_trialing && active_or_trialing["seats"].to_i == (body[:seats] || 1).to_i
291
+ same_seats = auto_managed_seats || (active_or_trialing && active_or_trialing["seats"].to_i == requested_seats.to_i)
272
292
  same_price = !active_stripe || stripe_price_id_value == price_id
273
293
  valid_period = !active_or_trialing || !active_or_trialing["periodEnd"] || active_or_trialing["periodEnd"] > Time.now
274
294
  if active_or_trialing&.fetch("status", nil) == "active" && same_plan && same_seats && same_price && valid_period
@@ -276,6 +296,19 @@ module BetterAuth
276
296
  end
277
297
 
278
298
  if active_stripe
299
+ stripe_release_plugin_schedule(ctx, config, customer_id, active_stripe, active_or_trialing || subscription_to_update)
300
+
301
+ if body[:schedule_at_period_end]
302
+ url = stripe_schedule_plan_change(ctx, config, active_stripe, active_or_trialing, plan, price_id, requested_seats, seat_only_plan, body)
303
+ next ctx.json({url: url, redirect: stripe_redirect?(body)})
304
+ end
305
+
306
+ old_plan = active_or_trialing && stripe_plan_by_name(config, active_or_trialing["plan"])
307
+ if stripe_direct_subscription_update?(old_plan, plan, auto_managed_seats)
308
+ url = stripe_update_active_subscription_items(ctx, config, active_stripe, active_or_trialing, old_plan, plan, price_id, requested_seats, seat_only_plan, body)
309
+ next ctx.json({url: url, redirect: stripe_redirect?(body)})
310
+ end
311
+
279
312
  portal = stripe_client(config).billing_portal.sessions.create(
280
313
  customer: customer_id,
281
314
  return_url: stripe_url(ctx, body[:return_url] || "/"),
@@ -284,7 +317,7 @@ module BetterAuth
284
317
  after_completion: {type: "redirect", redirect: {return_url: stripe_url(ctx, body[:return_url] || "/")}},
285
318
  subscription_update_confirm: {
286
319
  subscription: stripe_fetch(active_stripe, "id"),
287
- items: [{id: stripe_fetch(active_stripe_item || {}, "id"), quantity: body[:seats] || 1, price: price_id}]
320
+ items: [stripe_line_item(config, price_id, requested_seats).merge(id: stripe_fetch(active_stripe_item || {}, "id"))]
288
321
  }
289
322
  }
290
323
  )
@@ -294,12 +327,12 @@ module BetterAuth
294
327
  incomplete = subscriptions.find { |entry| entry["status"] == "incomplete" }
295
328
  subscription = active_or_trialing || incomplete
296
329
  if subscription
297
- update = {plan: plan[:name].to_s.downcase, seats: body[:seats] || 1}
330
+ update = {plan: plan[:name].to_s.downcase, seats: requested_seats}
298
331
  subscription = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: update) || subscription.merge(update.transform_keys { |key| Schema.storage_key(key) })
299
332
  else
300
333
  subscription = ctx.context.adapter.create(
301
334
  model: "subscription",
302
- data: {plan: plan[:name].to_s.downcase, referenceId: reference_id, stripeCustomerId: customer_id, status: "incomplete", seats: body[:seats] || 1, limits: plan[:limits]}
335
+ data: {plan: plan[:name].to_s.downcase, referenceId: reference_id, stripeCustomerId: customer_id, status: "incomplete", seats: requested_seats, limits: plan[:limits]}
303
336
  )
304
337
  end
305
338
 
@@ -323,9 +356,9 @@ module BetterAuth
323
356
  customer: customer_id,
324
357
  customer_update: (customer_type == "user") ? {name: "auto", address: "auto"} : {address: "auto"},
325
358
  locale: body[:locale],
326
- success_url: stripe_url(ctx, "#{ctx.context.base_url}/subscription/success?callbackURL=#{Rack::Utils.escape(body[:success_url] || "/")}&subscriptionId=#{Rack::Utils.escape(subscription.fetch("id"))}"),
359
+ success_url: stripe_url(ctx, "#{ctx.context.base_url}/subscription/success?callbackURL=#{Rack::Utils.escape(body[:success_url] || "/")}&checkoutSessionId={CHECKOUT_SESSION_ID}"),
327
360
  cancel_url: stripe_url(ctx, body[:cancel_url] || "/"),
328
- line_items: [{price: price_id, quantity: body[:seats] || 1}],
361
+ line_items: stripe_checkout_line_items(config, plan, price_id, requested_seats, auto_managed_seats, seat_only_plan),
329
362
  subscription_data: free_trial.merge(metadata: subscription_metadata),
330
363
  mode: "subscription",
331
364
  client_reference_id: reference_id,
@@ -343,7 +376,7 @@ module BetterAuth
343
376
  Endpoint.new(path: "/subscription/cancel", method: "POST") do |ctx|
344
377
  session = Routes.current_session(ctx)
345
378
  body = normalize_hash(ctx.body)
346
- customer_type = (body[:customer_type] || "user").to_s
379
+ customer_type = stripe_customer_type!(body)
347
380
  reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
348
381
  stripe_authorize_reference!(ctx, session, reference_id, "cancel-subscription", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
349
382
  subscription = stripe_find_subscription_for_action(ctx, reference_id, body[:subscription_id], active_only: true)
@@ -378,12 +411,22 @@ module BetterAuth
378
411
  Endpoint.new(path: "/subscription/restore", method: "POST") do |ctx|
379
412
  session = Routes.current_session(ctx)
380
413
  body = normalize_hash(ctx.body)
381
- customer_type = (body[:customer_type] || "user").to_s
414
+ customer_type = stripe_customer_type!(body)
382
415
  reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
383
416
  stripe_authorize_reference!(ctx, session, reference_id, "restore-subscription", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
384
417
  subscription = stripe_find_subscription_for_action(ctx, reference_id, body[:subscription_id], active_only: false)
385
418
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription && subscription["stripeCustomerId"]
386
419
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_ACTIVE")) unless stripe_active_or_trialing?(subscription)
420
+
421
+ if subscription["stripeScheduleId"]
422
+ schedule = stripe_client(config).subscription_schedules.retrieve(subscription["stripeScheduleId"])
423
+ if stripe_fetch(schedule, "status") == "active"
424
+ schedule = stripe_client(config).subscription_schedules.release(subscription["stripeScheduleId"])
425
+ end
426
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {stripeScheduleId: nil})
427
+ next ctx.json(stripe_stringify_keys(schedule))
428
+ end
429
+
387
430
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION")) unless stripe_pending_cancel?(subscription)
388
431
 
389
432
  active = stripe_active_subscriptions(config, subscription["stripeCustomerId"]).first
@@ -406,14 +449,19 @@ module BetterAuth
406
449
  Endpoint.new(path: "/subscription/list", method: "GET") do |ctx|
407
450
  session = Routes.current_session(ctx)
408
451
  query = normalize_hash(ctx.query)
409
- customer_type = (query[:customer_type] || "user").to_s
452
+ customer_type = stripe_customer_type!(query)
410
453
  reference_id = stripe_reference_id!(ctx, session, customer_type, query[:reference_id], config)
411
454
  stripe_authorize_reference!(ctx, session, reference_id, "list-subscription", customer_type, stripe_subscription_options(config), explicit: query.key?(:reference_id))
412
455
  plans = stripe_plans(config)
413
456
  subscriptions = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).select { |entry| stripe_active_or_trialing?(entry) }
414
457
  ctx.json(subscriptions.map do |entry|
415
458
  plan = plans.find { |item| item[:name].to_s.downcase == entry["plan"].to_s.downcase }
416
- entry.merge("limits" => plan&.fetch(:limits, nil), "priceId" => plan&.fetch(:price_id, nil))
459
+ price_id = if entry["billingInterval"] == "year"
460
+ plan&.fetch(:annual_discount_price_id, nil) || plan&.fetch(:price_id, nil)
461
+ else
462
+ plan&.fetch(:price_id, nil)
463
+ end
464
+ entry.merge("limits" => plan&.fetch(:limits, nil), "priceId" => price_id)
417
465
  end)
418
466
  end
419
467
  end
@@ -422,7 +470,7 @@ module BetterAuth
422
470
  Endpoint.new(path: "/subscription/billing-portal", method: "POST") do |ctx|
423
471
  session = Routes.current_session(ctx)
424
472
  body = normalize_hash(ctx.body)
425
- customer_type = (body[:customer_type] || "user").to_s
473
+ customer_type = stripe_customer_type!(body)
426
474
  reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
427
475
  stripe_authorize_reference!(ctx, session, reference_id, "billing-portal", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
428
476
  customer_id = if customer_type == "organization"
@@ -468,13 +516,28 @@ module BetterAuth
468
516
  Endpoint.new(path: "/subscription/success", method: "GET") do |ctx|
469
517
  query = normalize_hash(ctx.query)
470
518
  callback = query[:callback_url] || "/"
471
- unless query[:subscription_id]
519
+ checkout_session_id = query[:checkout_session_id]
520
+ subscription_id = query[:subscription_id]
521
+ if checkout_session_id
522
+ callback = callback.to_s.gsub("{CHECKOUT_SESSION_ID}", checkout_session_id.to_s)
523
+ checkout_session = begin
524
+ stripe_client(config || {}).checkout.sessions.retrieve(checkout_session_id)
525
+ rescue
526
+ nil
527
+ end
528
+ raise ctx.redirect(stripe_url(ctx, callback)) unless checkout_session
529
+
530
+ metadata = normalize_hash(stripe_fetch(checkout_session || {}, "metadata") || {})
531
+ subscription_id = metadata[:subscription_id]
532
+ end
533
+
534
+ unless subscription_id
472
535
  raise ctx.redirect(stripe_url(ctx, callback))
473
536
  end
474
537
  session = Routes.current_session(ctx, allow_nil: true)
475
538
  raise ctx.redirect(stripe_url(ctx, callback)) unless session
476
539
 
477
- subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: query[:subscription_id]}])
540
+ subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: subscription_id}])
478
541
  raise ctx.redirect(stripe_url(ctx, callback)) unless subscription
479
542
  raise ctx.redirect(stripe_url(ctx, callback)) if stripe_active_or_trialing?(subscription)
480
543
 
@@ -483,15 +546,16 @@ module BetterAuth
483
546
 
484
547
  stripe_subscription = stripe_active_subscriptions(config || {}, customer_id).first
485
548
  if stripe_subscription
486
- item = stripe_subscription_item(stripe_subscription)
487
- plan = item && stripe_plan_by_price_info(config || {}, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key"))
549
+ resolved = stripe_resolve_plan_item(config || {}, stripe_subscription)
550
+ item = resolved&.fetch(:item, nil)
551
+ plan = resolved&.fetch(:plan, nil)
488
552
  if item && plan
489
553
  ctx.context.adapter.update(
490
554
  model: "subscription",
491
555
  where: [{field: "id", value: subscription.fetch("id")}],
492
- update: stripe_subscription_state(stripe_subscription, include_status: true).merge(
556
+ update: stripe_subscription_state(stripe_subscription, include_status: true, compact: false).merge(
493
557
  plan: plan[:name].to_s.downcase,
494
- seats: stripe_fetch(item, "quantity") || 1,
558
+ seats: stripe_resolve_quantity(stripe_subscription, item, plan),
495
559
  stripeSubscriptionId: stripe_fetch(stripe_subscription, "id")
496
560
  )
497
561
  )
@@ -508,23 +572,27 @@ module BetterAuth
508
572
 
509
573
  raise APIError.new("INTERNAL_SERVER_ERROR", message: STRIPE_ERROR_CODES.fetch("STRIPE_WEBHOOK_SECRET_NOT_FOUND")) if config[:stripe_webhook_secret].to_s.empty?
510
574
 
511
- event = if stripe_client(config).respond_to?(:webhooks)
512
- webhooks = stripe_client(config).webhooks
513
- if webhooks.respond_to?(:construct_event_async) && (!webhooks.respond_to?(:construct_event) || config[:stripe_async_webhooks] || (webhooks.respond_to?(:async) && webhooks.async))
514
- webhooks.construct_event_async(ctx.body, signature, config[:stripe_webhook_secret])
575
+ event = begin
576
+ if stripe_client(config).respond_to?(:webhooks)
577
+ webhooks = stripe_client(config).webhooks
578
+ if webhooks.respond_to?(:construct_event_async)
579
+ webhooks.construct_event_async(ctx.body, signature, config[:stripe_webhook_secret])
580
+ else
581
+ webhooks.construct_event(ctx.body, signature, config[:stripe_webhook_secret])
582
+ end
515
583
  else
516
- webhooks.construct_event(ctx.body, signature, config[:stripe_webhook_secret])
584
+ ctx.body
517
585
  end
518
- else
519
- ctx.body
586
+ rescue
587
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT"))
520
588
  end
521
589
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT")) unless event
522
- stripe_handle_event(ctx, event)
590
+ begin
591
+ stripe_handle_event(ctx, event)
592
+ rescue
593
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("STRIPE_WEBHOOK_ERROR"))
594
+ end
523
595
  ctx.json({success: true})
524
- rescue APIError
525
- raise
526
- rescue => error
527
- raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT") || error.message)
528
596
  end
529
597
  end
530
598
 
@@ -551,10 +619,11 @@ module BetterAuth
551
619
  return if object[:mode] == "setup" || !config.dig(:subscription, :enabled)
552
620
 
553
621
  stripe_subscription = stripe_client(config).subscriptions.retrieve(object[:subscription])
554
- item = stripe_subscription_item(stripe_subscription)
555
- return unless item
622
+ resolved = stripe_resolve_plan_item(config, stripe_subscription)
623
+ return unless resolved
556
624
 
557
- plan = stripe_plan_by_price_info(config, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key"))
625
+ item = resolved.fetch(:item)
626
+ plan = resolved.fetch(:plan)
558
627
  metadata = normalize_hash(object[:metadata] || {})
559
628
  reference_id = object[:client_reference_id] || metadata[:reference_id]
560
629
  subscription_id = metadata[:subscription_id]
@@ -563,7 +632,7 @@ module BetterAuth
563
632
  update = stripe_subscription_state(stripe_subscription, include_status: true).merge(
564
633
  plan: plan[:name].to_s.downcase,
565
634
  stripeSubscriptionId: object[:subscription],
566
- seats: stripe_fetch(item, "quantity"),
635
+ seats: stripe_resolve_quantity(stripe_subscription, item, plan),
567
636
  trialStart: stripe_time(stripe_fetch(stripe_subscription, "trial_start")),
568
637
  trialEnd: stripe_time(stripe_fetch(stripe_subscription, "trial_end"))
569
638
  ).compact
@@ -588,9 +657,10 @@ module BetterAuth
588
657
 
589
658
  reference = stripe_reference_by_customer(ctx, config, customer_id) || ((metadata[:reference_id] && metadata[:plan]) ? {reference_id: metadata[:reference_id], customer_type: metadata[:customer_type] || "user"} : nil)
590
659
  return unless reference
591
- item = stripe_subscription_item(object)
592
- return unless item
593
- plan = stripe_plan_by_price_info(config, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key")) || (metadata[:plan] && stripe_plan_by_name(config, metadata[:plan]))
660
+ resolved = stripe_resolve_plan_item(config, object)
661
+ return unless resolved
662
+ item = resolved.fetch(:item)
663
+ plan = resolved[:plan] || (metadata[:plan] && stripe_plan_by_name(config, metadata[:plan]))
594
664
  return unless plan
595
665
 
596
666
  created = ctx.context.adapter.create(
@@ -600,7 +670,7 @@ module BetterAuth
600
670
  stripeCustomerId: customer_id,
601
671
  stripeSubscriptionId: object[:id],
602
672
  plan: plan[:name].to_s.downcase,
603
- seats: stripe_fetch(item, "quantity"),
673
+ seats: stripe_resolve_quantity(object, item, plan),
604
674
  limits: plan[:limits]
605
675
  ).compact
606
676
  )
@@ -610,8 +680,9 @@ module BetterAuth
610
680
  def stripe_on_subscription_updated(ctx, event)
611
681
  config = ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
612
682
  object = normalize_hash(event.dig(:data, :object) || {})
613
- item = stripe_subscription_item(object)
614
- return unless item
683
+ resolved = stripe_resolve_plan_item(config, object)
684
+ return unless resolved
685
+ item = resolved.fetch(:item)
615
686
 
616
687
  metadata = normalize_hash(object[:metadata] || {})
617
688
  subscription = if metadata[:subscription_id]
@@ -629,19 +700,25 @@ module BetterAuth
629
700
  end
630
701
  return unless subscription
631
702
 
632
- plan = stripe_plan_by_price_info(config, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key"))
703
+ plan = resolved[:plan]
633
704
  was_pending = stripe_pending_cancel?(subscription)
634
- update = stripe_subscription_state(object, include_status: true).merge(
705
+ update = stripe_subscription_state(object, include_status: true, compact: false).merge(
635
706
  stripeSubscriptionId: object[:id],
636
- seats: stripe_fetch(item, "quantity")
707
+ seats: stripe_resolve_quantity(object, item, plan)
637
708
  )
638
709
  update[:plan] = plan[:name].to_s.downcase if plan
639
710
  update[:limits] = plan[:limits] if plan&.key?(:limits)
640
- updated = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: update.compact)
711
+ updated = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: update)
641
712
  if object[:status] == "active" && stripe_stripe_pending_cancel?(object) && !was_pending
642
713
  config.dig(:subscription, :on_subscription_cancel)&.call({event: event, subscription: subscription, stripeSubscription: object, stripe_subscription: object, cancellationDetails: object[:cancellation_details], cancellation_details: object[:cancellation_details]})
643
714
  end
644
715
  config.dig(:subscription, :on_subscription_update)&.call({event: event, subscription: updated || subscription})
716
+ if plan && subscription["status"] == "trialing" && object[:status] == "active"
717
+ plan.dig(:free_trial, :on_trial_end)&.call({subscription: subscription}, ctx)
718
+ end
719
+ if plan && subscription["status"] == "trialing" && object[:status] == "incomplete_expired"
720
+ plan.dig(:free_trial, :on_trial_expired)&.call(subscription, ctx)
721
+ end
645
722
  end
646
723
 
647
724
  def stripe_on_subscription_deleted(ctx, event)
@@ -650,7 +727,7 @@ module BetterAuth
650
727
  subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
651
728
  return unless subscription
652
729
 
653
- ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: stripe_subscription_state(object, include_status: false).merge(status: "canceled"))
730
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: stripe_subscription_state(object, include_status: false, compact: false).merge(status: "canceled", stripeScheduleId: nil))
654
731
  config.dig(:subscription, :on_subscription_deleted)&.call({event: event, subscription: subscription, stripeSubscription: object, stripe_subscription: object})
655
732
  end
656
733
 
@@ -662,8 +739,7 @@ module BetterAuth
662
739
  end
663
740
 
664
741
  def stripe_find_or_create_user_customer(config, user, metadata = nil, ctx = nil)
665
- existing = stripe_client(config).customers.search(query: "email:\"#{stripe_escape_search(user["email"])}\" AND -metadata[\"customerType\"]:\"organization\"", limit: 1)
666
- customer = Array(stripe_fetch(existing, "data")).first
742
+ customer = stripe_find_user_customer(config, user["email"])
667
743
  if customer
668
744
  stripe_notify_customer_created(config, customer, user, ctx)
669
745
  return customer
@@ -691,8 +767,7 @@ module BetterAuth
691
767
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless org
692
768
  return org["stripeCustomerId"] if org["stripeCustomerId"]
693
769
 
694
- existing = stripe_client(config).customers.search(query: "metadata[\"organizationId\"]:\"#{stripe_escape_search(org["id"])}\"", limit: 1)
695
- customer = Array(stripe_fetch(existing, "data")).first
770
+ customer = stripe_find_organization_customer(config, org["id"])
696
771
  unless customer
697
772
  raw_extra = config.dig(:organization, :get_customer_create_params)&.call(org, ctx) || {}
698
773
  extra_metadata = stripe_fetch(raw_extra, "metadata")
@@ -748,7 +823,12 @@ module BetterAuth
748
823
  def stripe_plans(config)
749
824
  plans = stripe_subscription_options(config)[:plans] || []
750
825
  plans = plans.call if plans.respond_to?(:call)
751
- Array(plans).map { |plan| normalize_hash(plan) }
826
+ Array(plans).map do |plan|
827
+ normalized = normalize_hash(plan)
828
+ limits = stripe_fetch(plan, "limits")
829
+ normalized[:limits] = limits if limits
830
+ normalized
831
+ end
752
832
  end
753
833
 
754
834
  def stripe_plan_by_name(config, name)
@@ -778,7 +858,7 @@ module BetterAuth
778
858
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_SUBSCRIPTION_NOT_ENABLED")) unless config.dig(:organization, :enabled)
779
859
 
780
860
  reference_id = explicit_reference_id || session.fetch(:session)["activeOrganizationId"]
781
- raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) if reference_id.to_s.empty?
861
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_REFERENCE_ID_REQUIRED")) if reference_id.to_s.empty?
782
862
  reference_id
783
863
  end
784
864
 
@@ -796,6 +876,41 @@ module BetterAuth
796
876
  raise APIError.new("UNAUTHORIZED", message: STRIPE_ERROR_CODES.fetch("UNAUTHORIZED")) unless allowed
797
877
  end
798
878
 
879
+ def stripe_customer_type!(source)
880
+ customer_type = (source[:customer_type] || "user").to_s
881
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("INVALID_CUSTOMER_TYPE")) unless %w[user organization].include?(customer_type)
882
+
883
+ customer_type
884
+ end
885
+
886
+ def stripe_find_user_customer(config, email)
887
+ customers = stripe_client(config).customers
888
+ begin
889
+ existing = customers.search(query: "email:\"#{stripe_escape_search(email)}\" AND -metadata[\"customerType\"]:\"organization\"", limit: 1)
890
+ Array(stripe_fetch(existing, "data")).first
891
+ rescue
892
+ listed = customers.list(email: email, limit: 100)
893
+ Array(stripe_fetch(listed, "data")).find do |customer|
894
+ stripe_metadata_fetch(stripe_fetch(customer, "metadata") || {}, "customerType") != "organization"
895
+ end
896
+ end
897
+ end
898
+
899
+ def stripe_find_organization_customer(config, organization_id)
900
+ customers = stripe_client(config).customers
901
+ begin
902
+ existing = customers.search(query: "metadata[\"organizationId\"]:\"#{stripe_escape_search(organization_id)}\" AND metadata[\"customerType\"]:\"organization\"", limit: 1)
903
+ Array(stripe_fetch(existing, "data")).first
904
+ rescue
905
+ listed = customers.list(limit: 100)
906
+ Array(stripe_fetch(listed, "data")).find do |customer|
907
+ metadata = stripe_fetch(customer, "metadata") || {}
908
+ stripe_metadata_fetch(metadata, "organizationId") == organization_id &&
909
+ stripe_metadata_fetch(metadata, "customerType") == "organization"
910
+ end
911
+ end
912
+ end
913
+
799
914
  def stripe_find_subscription_for_action(ctx, reference_id, subscription_id, active_only:)
800
915
  subscription = if subscription_id
801
916
  ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: subscription_id}])
@@ -832,8 +947,214 @@ module BetterAuth
832
947
  Array(stripe_fetch(stripe_fetch(subscription, "items") || {}, "data")).first
833
948
  end
834
949
 
835
- def stripe_subscription_state(subscription, include_status: true)
950
+ def stripe_resolve_plan_item(config, subscription)
951
+ items = Array(stripe_fetch(stripe_fetch(subscription, "items") || {}, "data"))
952
+ first = items.first
953
+ return nil unless first
954
+
955
+ items.each do |item|
956
+ price = stripe_fetch(item, "price") || {}
957
+ plan = stripe_plan_by_price_info(config, stripe_fetch(price, "id"), stripe_fetch(price, "lookup_key"))
958
+ return {item: item, plan: plan} if plan
959
+ end
960
+ {item: first, plan: nil} if items.length == 1
961
+ end
962
+
963
+ def stripe_resolve_quantity(subscription, plan_item, plan = nil)
964
+ items = Array(stripe_fetch(stripe_fetch(subscription, "items") || {}, "data"))
965
+ seat_price_id = plan && plan[:seat_price_id]
966
+ seat_item = seat_price_id && items.find { |item| stripe_fetch(stripe_fetch(item, "price") || {}, "id") == seat_price_id }
967
+ stripe_fetch(seat_item || plan_item, "quantity") || 1
968
+ end
969
+
970
+ def stripe_line_item(config, price_id, quantity)
971
+ item = {price: price_id}
972
+ item[:quantity] = quantity unless stripe_metered_price?(config, price_id)
973
+ item
974
+ end
975
+
976
+ def stripe_checkout_line_items(config, plan, price_id, quantity, auto_managed_seats, seat_only_plan)
977
+ items = []
978
+ items << stripe_line_item(config, price_id, auto_managed_seats ? 1 : quantity) unless seat_only_plan
979
+ items << {price: plan[:seat_price_id], quantity: quantity} if auto_managed_seats && plan[:seat_price_id]
980
+ items.concat(stripe_plan_line_items(plan))
981
+ items
982
+ end
983
+
984
+ def stripe_plan_line_items(plan)
985
+ Array(plan[:line_items]).map do |item|
986
+ item.is_a?(Hash) ? normalize_hash(item) : item
987
+ end
988
+ end
989
+
990
+ def stripe_schedule_plan_change(ctx, config, active_stripe, db_subscription, plan, price_id, quantity, seat_only_plan, body)
991
+ schedule = stripe_client(config).subscription_schedules.create(from_subscription: stripe_fetch(active_stripe, "id"))
992
+ current_phase = Array(stripe_fetch(schedule, "phases")).first || {}
993
+ current_items = Array(stripe_fetch(current_phase, "items"))
994
+ active_item = stripe_resolve_plan_item(config, active_stripe)&.fetch(:item, nil) || stripe_subscription_item(active_stripe)
995
+ active_price_id = stripe_fetch(stripe_fetch(active_item || {}, "price") || {}, "id")
996
+ replaced = false
997
+ new_items = current_items.filter_map do |item|
998
+ item_price = stripe_fetch(item, "price")
999
+ item_price = stripe_fetch(item_price, "id") if item_price.is_a?(Hash)
1000
+ if item_price == active_price_id
1001
+ replaced = true
1002
+ next nil if seat_only_plan
1003
+
1004
+ stripe_line_item(config, price_id, quantity)
1005
+ else
1006
+ {price: item_price, quantity: stripe_fetch(item, "quantity")}.compact
1007
+ end
1008
+ end
1009
+ new_items << stripe_line_item(config, price_id, quantity) unless replaced || seat_only_plan
1010
+ new_items << {price: plan[:seat_price_id], quantity: quantity} if plan[:seat_price_id]
1011
+ new_items.concat(stripe_plan_line_items(plan))
1012
+
1013
+ stripe_client(config).subscription_schedules.update(
1014
+ stripe_fetch(schedule, "id"),
1015
+ metadata: {source: "@better-auth/stripe"},
1016
+ end_behavior: "release",
1017
+ phases: [
1018
+ {
1019
+ items: current_items.map do |item|
1020
+ item_price = stripe_fetch(item, "price")
1021
+ item_price = stripe_fetch(item_price, "id") if item_price.is_a?(Hash)
1022
+ {price: item_price, quantity: stripe_fetch(item, "quantity")}.compact
1023
+ end,
1024
+ start_date: stripe_fetch(current_phase, "start_date"),
1025
+ end_date: stripe_fetch(current_phase, "end_date")
1026
+ },
1027
+ {
1028
+ items: new_items,
1029
+ start_date: stripe_fetch(current_phase, "end_date"),
1030
+ proration_behavior: "none"
1031
+ }
1032
+ ]
1033
+ )
1034
+ if db_subscription
1035
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: db_subscription.fetch("id")}], update: {stripeScheduleId: stripe_fetch(schedule, "id")})
1036
+ end
1037
+ stripe_url(ctx, body[:return_url] || "/")
1038
+ end
1039
+
1040
+ def stripe_release_plugin_schedule(ctx, config, customer_id, active_stripe, db_subscription)
1041
+ return unless stripe_schedule_id(active_stripe)
1042
+ return unless stripe_client(config).respond_to?(:subscription_schedules)
1043
+
1044
+ schedules = stripe_client(config).subscription_schedules.list(customer: customer_id)
1045
+ active_subscription_id = stripe_fetch(active_stripe, "id")
1046
+ existing = Array(stripe_fetch(schedules, "data")).find do |schedule|
1047
+ subscription = stripe_fetch(schedule, "subscription")
1048
+ schedule_subscription_id = subscription.is_a?(Hash) ? stripe_id(subscription) : subscription
1049
+ metadata = stripe_fetch(schedule, "metadata") || {}
1050
+ schedule_subscription_id == active_subscription_id &&
1051
+ stripe_fetch(schedule, "status") == "active" &&
1052
+ stripe_metadata_fetch(metadata, "source") == "@better-auth/stripe"
1053
+ end
1054
+ return unless existing
1055
+
1056
+ stripe_client(config).subscription_schedules.release(stripe_id(existing))
1057
+ if db_subscription
1058
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: db_subscription.fetch("id")}], update: {stripeScheduleId: nil})
1059
+ end
1060
+ end
1061
+
1062
+ def stripe_direct_subscription_update?(old_plan, plan, auto_managed_seats)
1063
+ return true if auto_managed_seats && old_plan && old_plan[:seat_price_id] != plan[:seat_price_id]
1064
+
1065
+ stripe_plan_line_items(old_plan || {}).map { |item| item[:price] } != stripe_plan_line_items(plan).map { |item| item[:price] }
1066
+ end
1067
+
1068
+ def stripe_update_active_subscription_items(ctx, config, active_stripe, db_subscription, old_plan, plan, price_id, quantity, seat_only_plan, body)
1069
+ active_item = stripe_resolve_plan_item(config, active_stripe)&.fetch(:item, nil) || stripe_subscription_item(active_stripe)
1070
+ active_price_id = stripe_fetch(stripe_fetch(active_item || {}, "price") || {}, "id")
1071
+ old_line_prices = stripe_plan_line_items(old_plan || {}).map { |item| item[:price] }
1072
+ new_line_prices = stripe_plan_line_items(plan).map { |item| item[:price] }
1073
+ added_line_prices = new_line_prices - old_line_prices
1074
+ items = []
1075
+ Array(stripe_fetch(stripe_fetch(active_stripe, "items") || {}, "data")).each do |item|
1076
+ item_price = stripe_fetch(stripe_fetch(item, "price") || {}, "id")
1077
+ if item_price == active_price_id
1078
+ items << stripe_line_item(config, price_id, plan[:seat_price_id] ? 1 : quantity).merge(id: stripe_fetch(item, "id")) unless seat_only_plan
1079
+ elsif old_plan && item_price == old_plan[:seat_price_id] && plan[:seat_price_id]
1080
+ items << {id: stripe_fetch(item, "id"), price: plan[:seat_price_id], quantity: quantity}
1081
+ elsif old_line_prices.include?(item_price)
1082
+ if new_line_prices.include?(item_price)
1083
+ new_line_prices.delete_at(new_line_prices.index(item_price))
1084
+ else
1085
+ items << {id: stripe_fetch(item, "id"), deleted: true}
1086
+ end
1087
+ end
1088
+ end
1089
+ items << {price: plan[:seat_price_id], quantity: quantity} if plan[:seat_price_id] && !items.any? { |item| item[:price] == plan[:seat_price_id] || item[:id] && item[:price] == plan[:seat_price_id] }
1090
+ added_line_prices.each { |price| items << {price: price} }
1091
+ stripe_client(config).subscriptions.update(stripe_fetch(active_stripe, "id"), items: items, proration_behavior: plan[:proration_behavior] || "create_prorations")
1092
+ if db_subscription
1093
+ ctx.context.adapter.update(
1094
+ model: "subscription",
1095
+ where: [{field: "id", value: db_subscription.fetch("id")}],
1096
+ update: {plan: plan[:name].to_s.downcase, seats: quantity, limits: plan[:limits], stripeScheduleId: nil}
1097
+ )
1098
+ end
1099
+ stripe_url(ctx, body[:return_url] || "/")
1100
+ end
1101
+
1102
+ def stripe_sync_organization_seats(config, data, ctx)
1103
+ organization = data[:organization] || data["organization"]
1104
+ return unless config.dig(:subscription, :enabled) && organization && organization["stripeCustomerId"]
1105
+
1106
+ member_count = ctx.context.adapter.count(model: "member", where: [{field: "organizationId", value: organization.fetch("id")}])
1107
+ seat_plans = stripe_plans(config).select { |plan| plan[:seat_price_id] }
1108
+ return if seat_plans.empty?
1109
+
1110
+ subscription = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: organization.fetch("id")}]).find { |entry| stripe_active_or_trialing?(entry) }
1111
+ return unless subscription && subscription["stripeSubscriptionId"]
1112
+
1113
+ plan = seat_plans.find { |entry| entry[:name].to_s.downcase == subscription["plan"].to_s.downcase }
1114
+ return unless plan
1115
+
1116
+ stripe_subscription = stripe_client(config).subscriptions.retrieve(subscription["stripeSubscriptionId"])
1117
+ return unless stripe_active_or_trialing?(stripe_subscription)
1118
+
1119
+ items = Array(stripe_fetch(stripe_fetch(stripe_subscription, "items") || {}, "data"))
1120
+ seat_item = items.find { |item| stripe_fetch(stripe_fetch(item, "price") || {}, "id") == plan[:seat_price_id] }
1121
+ return if seat_item && stripe_fetch(seat_item, "quantity").to_i == member_count.to_i
1122
+
1123
+ update_items = if seat_item
1124
+ [{id: stripe_fetch(seat_item, "id"), quantity: member_count}]
1125
+ else
1126
+ [{price: plan[:seat_price_id], quantity: member_count}]
1127
+ end
1128
+ stripe_client(config).subscriptions.update(subscription["stripeSubscriptionId"], items: update_items, proration_behavior: plan[:proration_behavior] || "create_prorations")
1129
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {seats: member_count})
1130
+ rescue
1131
+ nil
1132
+ end
1133
+
1134
+ def stripe_metered_price?(config, price_id, lookup_key = nil)
1135
+ price = stripe_resolve_stripe_price(config, price_id, lookup_key)
1136
+ recurring = stripe_fetch(price || {}, "recurring") || {}
1137
+ stripe_fetch(recurring, "usage_type") == "metered"
1138
+ end
1139
+
1140
+ def stripe_resolve_stripe_price(config, price_id, lookup_key = nil)
1141
+ return nil unless stripe_client(config).respond_to?(:prices)
1142
+
1143
+ prices = stripe_client(config).prices
1144
+ if lookup_key
1145
+ result = prices.list(lookup_keys: [lookup_key], active: true, limit: 1)
1146
+ Array(stripe_fetch(result, "data")).first
1147
+ elsif price_id && prices.respond_to?(:retrieve)
1148
+ prices.retrieve(price_id)
1149
+ end
1150
+ rescue
1151
+ nil
1152
+ end
1153
+
1154
+ def stripe_subscription_state(subscription, include_status: true, compact: true)
836
1155
  item = stripe_subscription_item(subscription)
1156
+ price = stripe_fetch(item || {}, "price") || {}
1157
+ recurring = stripe_fetch(price, "recurring") || {}
837
1158
  state = {
838
1159
  periodStart: stripe_time(stripe_fetch(item || subscription, "current_period_start")),
839
1160
  periodEnd: stripe_time(stripe_fetch(item || subscription, "current_period_end")),
@@ -842,10 +1163,20 @@ module BetterAuth
842
1163
  canceledAt: stripe_time(stripe_fetch(subscription, "canceled_at")),
843
1164
  endedAt: stripe_time(stripe_fetch(subscription, "ended_at")),
844
1165
  trialStart: stripe_time(stripe_fetch(subscription, "trial_start")),
845
- trialEnd: stripe_time(stripe_fetch(subscription, "trial_end"))
1166
+ trialEnd: stripe_time(stripe_fetch(subscription, "trial_end")),
1167
+ billingInterval: stripe_fetch(recurring, "interval"),
1168
+ stripeScheduleId: stripe_schedule_id(subscription)
846
1169
  }
847
1170
  state[:status] = stripe_fetch(subscription, "status") if include_status
848
- state.compact
1171
+ compact ? state.compact : state
1172
+ end
1173
+
1174
+ def stripe_schedule_id(subscription)
1175
+ schedule = stripe_fetch(subscription, "schedule")
1176
+ return nil if schedule.nil?
1177
+ return schedule if schedule.is_a?(String)
1178
+
1179
+ stripe_id(schedule) || schedule.to_s
849
1180
  end
850
1181
 
851
1182
  def stripe_reference_by_customer(ctx, config, customer_id)
@@ -864,7 +1195,10 @@ module BetterAuth
864
1195
  .reduce({}) do |acc, entry|
865
1196
  next acc unless entry.respond_to?(:each)
866
1197
 
867
- acc.merge(entry.each_with_object({}) { |(key, value), result| result[stripe_metadata_key(key)] = value })
1198
+ acc.merge(entry.each_with_object({}) do |(key, value), result|
1199
+ metadata_key = stripe_metadata_key(key)
1200
+ result[metadata_key] = value unless STRIPE_UNSAFE_METADATA_KEYS.include?(metadata_key)
1201
+ end)
868
1202
  end
869
1203
  .merge(internal.transform_keys { |key| stripe_metadata_key(key) })
870
1204
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Stripe
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-stripe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala