better_auth-stripe 0.1.0 → 0.2.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: 19c8cba9e5f67da8b742ec37f48ef758124d373812879ae8af0a49e950962fc5
4
- data.tar.gz: c848c8261f7376eb71d2c75d7e2a5fd3b3ef6ba384b92c2891e75de41be4a75b
3
+ metadata.gz: cd6fea79b678b06d713919a087366ca4d6e0e160e5623e0e2b4c788e5041e7ae
4
+ data.tar.gz: 96fdab018ed1cdce8beb551052b36ebdea06d832daf178ea54727472ebd604a6
5
5
  SHA512:
6
- metadata.gz: 75dd173be79173cc0a40596f120b54f1160dfce41c98fa9ce2c60a9de13a91860e17b1f6d42840e798dbe44a9b8f50a3fdc31e8bc95c8c3b972955bd2c38c199
7
- data.tar.gz: be8037299688f25db5354ff51fdec3ba1925a0afa023116d3f054da294f4a8db16f1c2ceafb19c3e41c9e9110b00fa5cfa6cd7d617ce73243394df8ea927ba7a
6
+ metadata.gz: f8b8e916a57096cca616e465cdf4d5ab2688b4ed4592c794a8309f4c71523c8550750a8360b2e76e357743cb52258e214ddf04a28bb232e371302926a3af328d
7
+ data.tar.gz: 3b2f8193888d31bb65992147081a022c8952a152b3d5642b80364ad0c18e6c9a5e6cdce8c36c09287ba5823f3ffd95e65a82b92ced1f290024b0c69635bee93d
data/CHANGELOG.md CHANGED
@@ -2,4 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-04-29
6
+
7
+ - Aligned Stripe subscription, checkout, portal, webhook, customer, and organization flows with upstream Better Auth behavior.
8
+ - Expanded Stripe documentation and tests for subscription lifecycle and organization billing parity.
9
+
10
+ ## [0.1.0] - 2026-04-28
11
+
5
12
  - 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
 
@@ -264,11 +278,16 @@ module BetterAuth
264
278
 
265
279
  price_id = stripe_price_id(config, plan, body[:annual])
266
280
  raise APIError.new("BAD_REQUEST", message: "Price ID not found for the selected plan") if price_id.to_s.empty?
281
+ auto_managed_seats = !!(plan[:seat_price_id] && customer_type == "organization")
282
+ member_count = auto_managed_seats ? ctx.context.adapter.count(model: "member", where: [{field: "organizationId", value: reference_id}]) : 0
283
+ requested_seats = auto_managed_seats ? member_count : (body[:seats] || 1)
284
+ seat_only_plan = auto_managed_seats && plan[:seat_price_id] == price_id
267
285
 
268
- active_stripe_item = stripe_subscription_item(active_stripe || {})
286
+ active_resolved = active_stripe && stripe_resolve_plan_item(config, active_stripe)
287
+ active_stripe_item = active_resolved&.fetch(:item, nil) || stripe_subscription_item(active_stripe || {})
269
288
  stripe_price_id_value = stripe_fetch(stripe_fetch(active_stripe_item || {}, "price") || {}, "id")
270
289
  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
290
+ same_seats = auto_managed_seats || (active_or_trialing && active_or_trialing["seats"].to_i == requested_seats.to_i)
272
291
  same_price = !active_stripe || stripe_price_id_value == price_id
273
292
  valid_period = !active_or_trialing || !active_or_trialing["periodEnd"] || active_or_trialing["periodEnd"] > Time.now
274
293
  if active_or_trialing&.fetch("status", nil) == "active" && same_plan && same_seats && same_price && valid_period
@@ -276,6 +295,17 @@ module BetterAuth
276
295
  end
277
296
 
278
297
  if active_stripe
298
+ if body[:schedule_at_period_end]
299
+ url = stripe_schedule_plan_change(ctx, config, active_stripe, active_or_trialing, plan, price_id, requested_seats, seat_only_plan, body)
300
+ next ctx.json({url: url, redirect: stripe_redirect?(body)})
301
+ end
302
+
303
+ old_plan = active_or_trialing && stripe_plan_by_name(config, active_or_trialing["plan"])
304
+ if stripe_direct_subscription_update?(old_plan, plan, auto_managed_seats)
305
+ url = stripe_update_active_subscription_items(ctx, config, active_stripe, active_or_trialing, old_plan, plan, price_id, requested_seats, seat_only_plan, body)
306
+ next ctx.json({url: url, redirect: stripe_redirect?(body)})
307
+ end
308
+
279
309
  portal = stripe_client(config).billing_portal.sessions.create(
280
310
  customer: customer_id,
281
311
  return_url: stripe_url(ctx, body[:return_url] || "/"),
@@ -284,7 +314,7 @@ module BetterAuth
284
314
  after_completion: {type: "redirect", redirect: {return_url: stripe_url(ctx, body[:return_url] || "/")}},
285
315
  subscription_update_confirm: {
286
316
  subscription: stripe_fetch(active_stripe, "id"),
287
- items: [{id: stripe_fetch(active_stripe_item || {}, "id"), quantity: body[:seats] || 1, price: price_id}]
317
+ items: [stripe_line_item(config, price_id, requested_seats).merge(id: stripe_fetch(active_stripe_item || {}, "id"))]
288
318
  }
289
319
  }
290
320
  )
@@ -294,12 +324,12 @@ module BetterAuth
294
324
  incomplete = subscriptions.find { |entry| entry["status"] == "incomplete" }
295
325
  subscription = active_or_trialing || incomplete
296
326
  if subscription
297
- update = {plan: plan[:name].to_s.downcase, seats: body[:seats] || 1}
327
+ update = {plan: plan[:name].to_s.downcase, seats: requested_seats}
298
328
  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
329
  else
300
330
  subscription = ctx.context.adapter.create(
301
331
  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]}
332
+ data: {plan: plan[:name].to_s.downcase, referenceId: reference_id, stripeCustomerId: customer_id, status: "incomplete", seats: requested_seats, limits: plan[:limits]}
303
333
  )
304
334
  end
305
335
 
@@ -323,9 +353,9 @@ module BetterAuth
323
353
  customer: customer_id,
324
354
  customer_update: (customer_type == "user") ? {name: "auto", address: "auto"} : {address: "auto"},
325
355
  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"))}"),
356
+ success_url: stripe_url(ctx, "#{ctx.context.base_url}/subscription/success?callbackURL=#{Rack::Utils.escape(body[:success_url] || "/")}&checkoutSessionId={CHECKOUT_SESSION_ID}"),
327
357
  cancel_url: stripe_url(ctx, body[:cancel_url] || "/"),
328
- line_items: [{price: price_id, quantity: body[:seats] || 1}],
358
+ line_items: stripe_checkout_line_items(config, plan, price_id, requested_seats, auto_managed_seats, seat_only_plan),
329
359
  subscription_data: free_trial.merge(metadata: subscription_metadata),
330
360
  mode: "subscription",
331
361
  client_reference_id: reference_id,
@@ -343,7 +373,7 @@ module BetterAuth
343
373
  Endpoint.new(path: "/subscription/cancel", method: "POST") do |ctx|
344
374
  session = Routes.current_session(ctx)
345
375
  body = normalize_hash(ctx.body)
346
- customer_type = (body[:customer_type] || "user").to_s
376
+ customer_type = stripe_customer_type!(body)
347
377
  reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
348
378
  stripe_authorize_reference!(ctx, session, reference_id, "cancel-subscription", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
349
379
  subscription = stripe_find_subscription_for_action(ctx, reference_id, body[:subscription_id], active_only: true)
@@ -378,12 +408,22 @@ module BetterAuth
378
408
  Endpoint.new(path: "/subscription/restore", method: "POST") do |ctx|
379
409
  session = Routes.current_session(ctx)
380
410
  body = normalize_hash(ctx.body)
381
- customer_type = (body[:customer_type] || "user").to_s
411
+ customer_type = stripe_customer_type!(body)
382
412
  reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
383
413
  stripe_authorize_reference!(ctx, session, reference_id, "restore-subscription", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
384
414
  subscription = stripe_find_subscription_for_action(ctx, reference_id, body[:subscription_id], active_only: false)
385
415
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription && subscription["stripeCustomerId"]
386
416
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_ACTIVE")) unless stripe_active_or_trialing?(subscription)
417
+
418
+ if subscription["stripeScheduleId"]
419
+ schedule = stripe_client(config).subscription_schedules.retrieve(subscription["stripeScheduleId"])
420
+ if stripe_fetch(schedule, "status") == "active"
421
+ schedule = stripe_client(config).subscription_schedules.release(subscription["stripeScheduleId"])
422
+ end
423
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {stripeScheduleId: nil})
424
+ next ctx.json(stripe_stringify_keys(schedule))
425
+ end
426
+
387
427
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION")) unless stripe_pending_cancel?(subscription)
388
428
 
389
429
  active = stripe_active_subscriptions(config, subscription["stripeCustomerId"]).first
@@ -406,14 +446,19 @@ module BetterAuth
406
446
  Endpoint.new(path: "/subscription/list", method: "GET") do |ctx|
407
447
  session = Routes.current_session(ctx)
408
448
  query = normalize_hash(ctx.query)
409
- customer_type = (query[:customer_type] || "user").to_s
449
+ customer_type = stripe_customer_type!(query)
410
450
  reference_id = stripe_reference_id!(ctx, session, customer_type, query[:reference_id], config)
411
451
  stripe_authorize_reference!(ctx, session, reference_id, "list-subscription", customer_type, stripe_subscription_options(config), explicit: query.key?(:reference_id))
412
452
  plans = stripe_plans(config)
413
453
  subscriptions = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).select { |entry| stripe_active_or_trialing?(entry) }
414
454
  ctx.json(subscriptions.map do |entry|
415
455
  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))
456
+ price_id = if entry["billingInterval"] == "year"
457
+ plan&.fetch(:annual_discount_price_id, nil) || plan&.fetch(:price_id, nil)
458
+ else
459
+ plan&.fetch(:price_id, nil)
460
+ end
461
+ entry.merge("limits" => plan&.fetch(:limits, nil), "priceId" => price_id)
417
462
  end)
418
463
  end
419
464
  end
@@ -422,7 +467,7 @@ module BetterAuth
422
467
  Endpoint.new(path: "/subscription/billing-portal", method: "POST") do |ctx|
423
468
  session = Routes.current_session(ctx)
424
469
  body = normalize_hash(ctx.body)
425
- customer_type = (body[:customer_type] || "user").to_s
470
+ customer_type = stripe_customer_type!(body)
426
471
  reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
427
472
  stripe_authorize_reference!(ctx, session, reference_id, "billing-portal", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
428
473
  customer_id = if customer_type == "organization"
@@ -468,13 +513,22 @@ module BetterAuth
468
513
  Endpoint.new(path: "/subscription/success", method: "GET") do |ctx|
469
514
  query = normalize_hash(ctx.query)
470
515
  callback = query[:callback_url] || "/"
471
- unless query[:subscription_id]
516
+ checkout_session_id = query[:checkout_session_id]
517
+ subscription_id = query[:subscription_id]
518
+ if checkout_session_id
519
+ callback = callback.to_s.gsub("{CHECKOUT_SESSION_ID}", checkout_session_id.to_s)
520
+ checkout_session = stripe_client(config || {}).checkout.sessions.retrieve(checkout_session_id)
521
+ metadata = normalize_hash(stripe_fetch(checkout_session || {}, "metadata") || {})
522
+ subscription_id = metadata[:subscription_id]
523
+ end
524
+
525
+ unless subscription_id
472
526
  raise ctx.redirect(stripe_url(ctx, callback))
473
527
  end
474
528
  session = Routes.current_session(ctx, allow_nil: true)
475
529
  raise ctx.redirect(stripe_url(ctx, callback)) unless session
476
530
 
477
- subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: query[:subscription_id]}])
531
+ subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: subscription_id}])
478
532
  raise ctx.redirect(stripe_url(ctx, callback)) unless subscription
479
533
  raise ctx.redirect(stripe_url(ctx, callback)) if stripe_active_or_trialing?(subscription)
480
534
 
@@ -483,15 +537,16 @@ module BetterAuth
483
537
 
484
538
  stripe_subscription = stripe_active_subscriptions(config || {}, customer_id).first
485
539
  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"))
540
+ resolved = stripe_resolve_plan_item(config || {}, stripe_subscription)
541
+ item = resolved&.fetch(:item, nil)
542
+ plan = resolved&.fetch(:plan, nil)
488
543
  if item && plan
489
544
  ctx.context.adapter.update(
490
545
  model: "subscription",
491
546
  where: [{field: "id", value: subscription.fetch("id")}],
492
- update: stripe_subscription_state(stripe_subscription, include_status: true).merge(
547
+ update: stripe_subscription_state(stripe_subscription, include_status: true, compact: false).merge(
493
548
  plan: plan[:name].to_s.downcase,
494
- seats: stripe_fetch(item, "quantity") || 1,
549
+ seats: stripe_resolve_quantity(stripe_subscription, item, plan),
495
550
  stripeSubscriptionId: stripe_fetch(stripe_subscription, "id")
496
551
  )
497
552
  )
@@ -508,23 +563,27 @@ module BetterAuth
508
563
 
509
564
  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
565
 
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])
566
+ event = begin
567
+ if stripe_client(config).respond_to?(:webhooks)
568
+ webhooks = stripe_client(config).webhooks
569
+ if webhooks.respond_to?(:construct_event_async)
570
+ webhooks.construct_event_async(ctx.body, signature, config[:stripe_webhook_secret])
571
+ else
572
+ webhooks.construct_event(ctx.body, signature, config[:stripe_webhook_secret])
573
+ end
515
574
  else
516
- webhooks.construct_event(ctx.body, signature, config[:stripe_webhook_secret])
575
+ ctx.body
517
576
  end
518
- else
519
- ctx.body
577
+ rescue
578
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT"))
520
579
  end
521
580
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT")) unless event
522
- stripe_handle_event(ctx, event)
581
+ begin
582
+ stripe_handle_event(ctx, event)
583
+ rescue
584
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("STRIPE_WEBHOOK_ERROR"))
585
+ end
523
586
  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
587
  end
529
588
  end
530
589
 
@@ -551,10 +610,11 @@ module BetterAuth
551
610
  return if object[:mode] == "setup" || !config.dig(:subscription, :enabled)
552
611
 
553
612
  stripe_subscription = stripe_client(config).subscriptions.retrieve(object[:subscription])
554
- item = stripe_subscription_item(stripe_subscription)
555
- return unless item
613
+ resolved = stripe_resolve_plan_item(config, stripe_subscription)
614
+ return unless resolved
556
615
 
557
- plan = stripe_plan_by_price_info(config, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key"))
616
+ item = resolved.fetch(:item)
617
+ plan = resolved.fetch(:plan)
558
618
  metadata = normalize_hash(object[:metadata] || {})
559
619
  reference_id = object[:client_reference_id] || metadata[:reference_id]
560
620
  subscription_id = metadata[:subscription_id]
@@ -563,7 +623,7 @@ module BetterAuth
563
623
  update = stripe_subscription_state(stripe_subscription, include_status: true).merge(
564
624
  plan: plan[:name].to_s.downcase,
565
625
  stripeSubscriptionId: object[:subscription],
566
- seats: stripe_fetch(item, "quantity"),
626
+ seats: stripe_resolve_quantity(stripe_subscription, item, plan),
567
627
  trialStart: stripe_time(stripe_fetch(stripe_subscription, "trial_start")),
568
628
  trialEnd: stripe_time(stripe_fetch(stripe_subscription, "trial_end"))
569
629
  ).compact
@@ -588,9 +648,10 @@ module BetterAuth
588
648
 
589
649
  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
650
  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]))
651
+ resolved = stripe_resolve_plan_item(config, object)
652
+ return unless resolved
653
+ item = resolved.fetch(:item)
654
+ plan = resolved[:plan] || (metadata[:plan] && stripe_plan_by_name(config, metadata[:plan]))
594
655
  return unless plan
595
656
 
596
657
  created = ctx.context.adapter.create(
@@ -600,7 +661,7 @@ module BetterAuth
600
661
  stripeCustomerId: customer_id,
601
662
  stripeSubscriptionId: object[:id],
602
663
  plan: plan[:name].to_s.downcase,
603
- seats: stripe_fetch(item, "quantity"),
664
+ seats: stripe_resolve_quantity(object, item, plan),
604
665
  limits: plan[:limits]
605
666
  ).compact
606
667
  )
@@ -610,8 +671,9 @@ module BetterAuth
610
671
  def stripe_on_subscription_updated(ctx, event)
611
672
  config = ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
612
673
  object = normalize_hash(event.dig(:data, :object) || {})
613
- item = stripe_subscription_item(object)
614
- return unless item
674
+ resolved = stripe_resolve_plan_item(config, object)
675
+ return unless resolved
676
+ item = resolved.fetch(:item)
615
677
 
616
678
  metadata = normalize_hash(object[:metadata] || {})
617
679
  subscription = if metadata[:subscription_id]
@@ -629,19 +691,25 @@ module BetterAuth
629
691
  end
630
692
  return unless subscription
631
693
 
632
- plan = stripe_plan_by_price_info(config, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key"))
694
+ plan = resolved[:plan]
633
695
  was_pending = stripe_pending_cancel?(subscription)
634
- update = stripe_subscription_state(object, include_status: true).merge(
696
+ update = stripe_subscription_state(object, include_status: true, compact: false).merge(
635
697
  stripeSubscriptionId: object[:id],
636
- seats: stripe_fetch(item, "quantity")
698
+ seats: stripe_resolve_quantity(object, item, plan)
637
699
  )
638
700
  update[:plan] = plan[:name].to_s.downcase if plan
639
701
  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)
702
+ updated = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: update)
641
703
  if object[:status] == "active" && stripe_stripe_pending_cancel?(object) && !was_pending
642
704
  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
705
  end
644
706
  config.dig(:subscription, :on_subscription_update)&.call({event: event, subscription: updated || subscription})
707
+ if plan && subscription["status"] == "trialing" && object[:status] == "active"
708
+ plan.dig(:free_trial, :on_trial_end)&.call({subscription: subscription}, ctx)
709
+ end
710
+ if plan && subscription["status"] == "trialing" && object[:status] == "incomplete_expired"
711
+ plan.dig(:free_trial, :on_trial_expired)&.call(subscription, ctx)
712
+ end
645
713
  end
646
714
 
647
715
  def stripe_on_subscription_deleted(ctx, event)
@@ -650,7 +718,7 @@ module BetterAuth
650
718
  subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
651
719
  return unless subscription
652
720
 
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"))
721
+ 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
722
  config.dig(:subscription, :on_subscription_deleted)&.call({event: event, subscription: subscription, stripeSubscription: object, stripe_subscription: object})
655
723
  end
656
724
 
@@ -662,8 +730,7 @@ module BetterAuth
662
730
  end
663
731
 
664
732
  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
733
+ customer = stripe_find_user_customer(config, user["email"])
667
734
  if customer
668
735
  stripe_notify_customer_created(config, customer, user, ctx)
669
736
  return customer
@@ -691,8 +758,7 @@ module BetterAuth
691
758
  raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless org
692
759
  return org["stripeCustomerId"] if org["stripeCustomerId"]
693
760
 
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
761
+ customer = stripe_find_organization_customer(config, org["id"])
696
762
  unless customer
697
763
  raw_extra = config.dig(:organization, :get_customer_create_params)&.call(org, ctx) || {}
698
764
  extra_metadata = stripe_fetch(raw_extra, "metadata")
@@ -796,6 +862,41 @@ module BetterAuth
796
862
  raise APIError.new("UNAUTHORIZED", message: STRIPE_ERROR_CODES.fetch("UNAUTHORIZED")) unless allowed
797
863
  end
798
864
 
865
+ def stripe_customer_type!(source)
866
+ customer_type = (source[:customer_type] || "user").to_s
867
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("INVALID_CUSTOMER_TYPE")) unless %w[user organization].include?(customer_type)
868
+
869
+ customer_type
870
+ end
871
+
872
+ def stripe_find_user_customer(config, email)
873
+ customers = stripe_client(config).customers
874
+ begin
875
+ existing = customers.search(query: "email:\"#{stripe_escape_search(email)}\" AND -metadata[\"customerType\"]:\"organization\"", limit: 1)
876
+ Array(stripe_fetch(existing, "data")).first
877
+ rescue
878
+ listed = customers.list(email: email, limit: 100)
879
+ Array(stripe_fetch(listed, "data")).find do |customer|
880
+ stripe_metadata_fetch(stripe_fetch(customer, "metadata") || {}, "customerType") != "organization"
881
+ end
882
+ end
883
+ end
884
+
885
+ def stripe_find_organization_customer(config, organization_id)
886
+ customers = stripe_client(config).customers
887
+ begin
888
+ existing = customers.search(query: "metadata[\"organizationId\"]:\"#{stripe_escape_search(organization_id)}\" AND metadata[\"customerType\"]:\"organization\"", limit: 1)
889
+ Array(stripe_fetch(existing, "data")).first
890
+ rescue
891
+ listed = customers.list(limit: 100)
892
+ Array(stripe_fetch(listed, "data")).find do |customer|
893
+ metadata = stripe_fetch(customer, "metadata") || {}
894
+ stripe_metadata_fetch(metadata, "organizationId") == organization_id &&
895
+ stripe_metadata_fetch(metadata, "customerType") == "organization"
896
+ end
897
+ end
898
+ end
899
+
799
900
  def stripe_find_subscription_for_action(ctx, reference_id, subscription_id, active_only:)
800
901
  subscription = if subscription_id
801
902
  ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: subscription_id}])
@@ -832,8 +933,192 @@ module BetterAuth
832
933
  Array(stripe_fetch(stripe_fetch(subscription, "items") || {}, "data")).first
833
934
  end
834
935
 
835
- def stripe_subscription_state(subscription, include_status: true)
936
+ def stripe_resolve_plan_item(config, subscription)
937
+ items = Array(stripe_fetch(stripe_fetch(subscription, "items") || {}, "data"))
938
+ first = items.first
939
+ return nil unless first
940
+
941
+ items.each do |item|
942
+ price = stripe_fetch(item, "price") || {}
943
+ plan = stripe_plan_by_price_info(config, stripe_fetch(price, "id"), stripe_fetch(price, "lookup_key"))
944
+ return {item: item, plan: plan} if plan
945
+ end
946
+ {item: first, plan: nil} if items.length == 1
947
+ end
948
+
949
+ def stripe_resolve_quantity(subscription, plan_item, plan = nil)
950
+ items = Array(stripe_fetch(stripe_fetch(subscription, "items") || {}, "data"))
951
+ seat_price_id = plan && plan[:seat_price_id]
952
+ seat_item = seat_price_id && items.find { |item| stripe_fetch(stripe_fetch(item, "price") || {}, "id") == seat_price_id }
953
+ stripe_fetch(seat_item || plan_item, "quantity") || 1
954
+ end
955
+
956
+ def stripe_line_item(config, price_id, quantity)
957
+ item = {price: price_id}
958
+ item[:quantity] = quantity unless stripe_metered_price?(config, price_id)
959
+ item
960
+ end
961
+
962
+ def stripe_checkout_line_items(config, plan, price_id, quantity, auto_managed_seats, seat_only_plan)
963
+ items = []
964
+ items << stripe_line_item(config, price_id, auto_managed_seats ? 1 : quantity) unless seat_only_plan
965
+ items << {price: plan[:seat_price_id], quantity: quantity} if auto_managed_seats && plan[:seat_price_id]
966
+ items.concat(stripe_plan_line_items(plan))
967
+ items
968
+ end
969
+
970
+ def stripe_plan_line_items(plan)
971
+ Array(plan[:line_items]).map do |item|
972
+ item.is_a?(Hash) ? normalize_hash(item) : item
973
+ end
974
+ end
975
+
976
+ def stripe_schedule_plan_change(ctx, config, active_stripe, db_subscription, plan, price_id, quantity, seat_only_plan, body)
977
+ schedule = stripe_client(config).subscription_schedules.create(from_subscription: stripe_fetch(active_stripe, "id"))
978
+ current_phase = Array(stripe_fetch(schedule, "phases")).first || {}
979
+ current_items = Array(stripe_fetch(current_phase, "items"))
980
+ active_item = stripe_resolve_plan_item(config, active_stripe)&.fetch(:item, nil) || stripe_subscription_item(active_stripe)
981
+ active_price_id = stripe_fetch(stripe_fetch(active_item || {}, "price") || {}, "id")
982
+ replaced = false
983
+ new_items = current_items.filter_map do |item|
984
+ item_price = stripe_fetch(item, "price")
985
+ item_price = stripe_fetch(item_price, "id") if item_price.is_a?(Hash)
986
+ if item_price == active_price_id
987
+ replaced = true
988
+ next nil if seat_only_plan
989
+
990
+ stripe_line_item(config, price_id, quantity)
991
+ else
992
+ {price: item_price, quantity: stripe_fetch(item, "quantity")}.compact
993
+ end
994
+ end
995
+ new_items << stripe_line_item(config, price_id, quantity) unless replaced || seat_only_plan
996
+ new_items << {price: plan[:seat_price_id], quantity: quantity} if plan[:seat_price_id]
997
+ new_items.concat(stripe_plan_line_items(plan))
998
+
999
+ stripe_client(config).subscription_schedules.update(
1000
+ stripe_fetch(schedule, "id"),
1001
+ metadata: {source: "@better-auth/stripe"},
1002
+ end_behavior: "release",
1003
+ phases: [
1004
+ {
1005
+ items: current_items.map do |item|
1006
+ item_price = stripe_fetch(item, "price")
1007
+ item_price = stripe_fetch(item_price, "id") if item_price.is_a?(Hash)
1008
+ {price: item_price, quantity: stripe_fetch(item, "quantity")}.compact
1009
+ end,
1010
+ start_date: stripe_fetch(current_phase, "start_date"),
1011
+ end_date: stripe_fetch(current_phase, "end_date")
1012
+ },
1013
+ {
1014
+ items: new_items,
1015
+ start_date: stripe_fetch(current_phase, "end_date"),
1016
+ proration_behavior: "none"
1017
+ }
1018
+ ]
1019
+ )
1020
+ if db_subscription
1021
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: db_subscription.fetch("id")}], update: {stripeScheduleId: stripe_fetch(schedule, "id")})
1022
+ end
1023
+ stripe_url(ctx, body[:return_url] || "/")
1024
+ end
1025
+
1026
+ def stripe_direct_subscription_update?(old_plan, plan, auto_managed_seats)
1027
+ return true if auto_managed_seats && old_plan && old_plan[:seat_price_id] != plan[:seat_price_id]
1028
+
1029
+ stripe_plan_line_items(old_plan || {}).map { |item| item[:price] } != stripe_plan_line_items(plan).map { |item| item[:price] }
1030
+ end
1031
+
1032
+ def stripe_update_active_subscription_items(ctx, config, active_stripe, db_subscription, old_plan, plan, price_id, quantity, seat_only_plan, body)
1033
+ active_item = stripe_resolve_plan_item(config, active_stripe)&.fetch(:item, nil) || stripe_subscription_item(active_stripe)
1034
+ active_price_id = stripe_fetch(stripe_fetch(active_item || {}, "price") || {}, "id")
1035
+ old_line_prices = stripe_plan_line_items(old_plan || {}).map { |item| item[:price] }
1036
+ new_line_prices = stripe_plan_line_items(plan).map { |item| item[:price] }
1037
+ added_line_prices = new_line_prices - old_line_prices
1038
+ items = []
1039
+ Array(stripe_fetch(stripe_fetch(active_stripe, "items") || {}, "data")).each do |item|
1040
+ item_price = stripe_fetch(stripe_fetch(item, "price") || {}, "id")
1041
+ if item_price == active_price_id
1042
+ items << stripe_line_item(config, price_id, plan[:seat_price_id] ? 1 : quantity).merge(id: stripe_fetch(item, "id")) unless seat_only_plan
1043
+ elsif old_plan && item_price == old_plan[:seat_price_id] && plan[:seat_price_id]
1044
+ items << {id: stripe_fetch(item, "id"), price: plan[:seat_price_id], quantity: quantity}
1045
+ elsif old_line_prices.include?(item_price)
1046
+ if new_line_prices.include?(item_price)
1047
+ new_line_prices.delete_at(new_line_prices.index(item_price))
1048
+ else
1049
+ items << {id: stripe_fetch(item, "id"), deleted: true}
1050
+ end
1051
+ end
1052
+ end
1053
+ 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] }
1054
+ added_line_prices.each { |price| items << {price: price} }
1055
+ stripe_client(config).subscriptions.update(stripe_fetch(active_stripe, "id"), items: items, proration_behavior: plan[:proration_behavior] || "create_prorations")
1056
+ if db_subscription
1057
+ ctx.context.adapter.update(
1058
+ model: "subscription",
1059
+ where: [{field: "id", value: db_subscription.fetch("id")}],
1060
+ update: {plan: plan[:name].to_s.downcase, seats: quantity, limits: plan[:limits], stripeScheduleId: nil}
1061
+ )
1062
+ end
1063
+ stripe_url(ctx, body[:return_url] || "/")
1064
+ end
1065
+
1066
+ def stripe_sync_organization_seats(config, data, ctx)
1067
+ organization = data[:organization] || data["organization"]
1068
+ return unless config.dig(:subscription, :enabled) && organization && organization["stripeCustomerId"]
1069
+
1070
+ member_count = ctx.context.adapter.count(model: "member", where: [{field: "organizationId", value: organization.fetch("id")}])
1071
+ seat_plans = stripe_plans(config).select { |plan| plan[:seat_price_id] }
1072
+ return if seat_plans.empty?
1073
+
1074
+ subscription = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: organization.fetch("id")}]).find { |entry| stripe_active_or_trialing?(entry) }
1075
+ return unless subscription && subscription["stripeSubscriptionId"]
1076
+
1077
+ plan = seat_plans.find { |entry| entry[:name].to_s.downcase == subscription["plan"].to_s.downcase }
1078
+ return unless plan
1079
+
1080
+ stripe_subscription = stripe_client(config).subscriptions.retrieve(subscription["stripeSubscriptionId"])
1081
+ return unless stripe_active_or_trialing?(stripe_subscription)
1082
+
1083
+ items = Array(stripe_fetch(stripe_fetch(stripe_subscription, "items") || {}, "data"))
1084
+ seat_item = items.find { |item| stripe_fetch(stripe_fetch(item, "price") || {}, "id") == plan[:seat_price_id] }
1085
+ return if seat_item && stripe_fetch(seat_item, "quantity").to_i == member_count.to_i
1086
+
1087
+ update_items = if seat_item
1088
+ [{id: stripe_fetch(seat_item, "id"), quantity: member_count}]
1089
+ else
1090
+ [{price: plan[:seat_price_id], quantity: member_count}]
1091
+ end
1092
+ stripe_client(config).subscriptions.update(subscription["stripeSubscriptionId"], items: update_items, proration_behavior: plan[:proration_behavior] || "create_prorations")
1093
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {seats: member_count})
1094
+ rescue
1095
+ nil
1096
+ end
1097
+
1098
+ def stripe_metered_price?(config, price_id, lookup_key = nil)
1099
+ price = stripe_resolve_stripe_price(config, price_id, lookup_key)
1100
+ recurring = stripe_fetch(price || {}, "recurring") || {}
1101
+ stripe_fetch(recurring, "usage_type") == "metered"
1102
+ end
1103
+
1104
+ def stripe_resolve_stripe_price(config, price_id, lookup_key = nil)
1105
+ return nil unless stripe_client(config).respond_to?(:prices)
1106
+
1107
+ prices = stripe_client(config).prices
1108
+ if lookup_key
1109
+ result = prices.list(lookup_keys: [lookup_key], active: true, limit: 1)
1110
+ Array(stripe_fetch(result, "data")).first
1111
+ elsif price_id && prices.respond_to?(:retrieve)
1112
+ prices.retrieve(price_id)
1113
+ end
1114
+ rescue
1115
+ nil
1116
+ end
1117
+
1118
+ def stripe_subscription_state(subscription, include_status: true, compact: true)
836
1119
  item = stripe_subscription_item(subscription)
1120
+ price = stripe_fetch(item || {}, "price") || {}
1121
+ recurring = stripe_fetch(price, "recurring") || {}
837
1122
  state = {
838
1123
  periodStart: stripe_time(stripe_fetch(item || subscription, "current_period_start")),
839
1124
  periodEnd: stripe_time(stripe_fetch(item || subscription, "current_period_end")),
@@ -842,10 +1127,20 @@ module BetterAuth
842
1127
  canceledAt: stripe_time(stripe_fetch(subscription, "canceled_at")),
843
1128
  endedAt: stripe_time(stripe_fetch(subscription, "ended_at")),
844
1129
  trialStart: stripe_time(stripe_fetch(subscription, "trial_start")),
845
- trialEnd: stripe_time(stripe_fetch(subscription, "trial_end"))
1130
+ trialEnd: stripe_time(stripe_fetch(subscription, "trial_end")),
1131
+ billingInterval: stripe_fetch(recurring, "interval"),
1132
+ stripeScheduleId: stripe_schedule_id(subscription)
846
1133
  }
847
1134
  state[:status] = stripe_fetch(subscription, "status") if include_status
848
- state.compact
1135
+ compact ? state.compact : state
1136
+ end
1137
+
1138
+ def stripe_schedule_id(subscription)
1139
+ schedule = stripe_fetch(subscription, "schedule")
1140
+ return nil if schedule.nil?
1141
+ return schedule if schedule.is_a?(String)
1142
+
1143
+ stripe_id(schedule) || schedule.to_s
849
1144
  end
850
1145
 
851
1146
  def stripe_reference_by_customer(ctx, config, customer_id)
@@ -864,7 +1159,10 @@ module BetterAuth
864
1159
  .reduce({}) do |acc, entry|
865
1160
  next acc unless entry.respond_to?(:each)
866
1161
 
867
- acc.merge(entry.each_with_object({}) { |(key, value), result| result[stripe_metadata_key(key)] = value })
1162
+ acc.merge(entry.each_with_object({}) do |(key, value), result|
1163
+ metadata_key = stripe_metadata_key(key)
1164
+ result[metadata_key] = value unless STRIPE_UNSAFE_METADATA_KEYS.include?(metadata_key)
1165
+ end)
868
1166
  end
869
1167
  .merge(internal.transform_keys { |key| stripe_metadata_key(key) })
870
1168
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Stripe
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala