better_auth-stripe 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 19c8cba9e5f67da8b742ec37f48ef758124d373812879ae8af0a49e950962fc5
4
+ data.tar.gz: c848c8261f7376eb71d2c75d7e2a5fd3b3ef6ba384b92c2891e75de41be4a75b
5
+ SHA512:
6
+ metadata.gz: 75dd173be79173cc0a40596f120b54f1160dfce41c98fa9ce2c60a9de13a91860e17b1f6d42840e798dbe44a9b8f50a3fdc31e8bc95c8c3b972955bd2c38c199
7
+ data.tar.gz: be8037299688f25db5354ff51fdec3ba1925a0afa023116d3f054da294f4a8db16f1c2ceafb19c3e41c9e9110b00fa5cfa6cd7d617ce73243394df8ea927ba7a
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ - Initial external Stripe package extracted from `better_auth`.
data/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # better_auth-stripe
2
+
3
+ Stripe subscription and customer plugin package for Better Auth Ruby.
4
+
5
+ ## Installation
6
+
7
+ Add the gem and require the package before configuring the plugin:
8
+
9
+ ```ruby
10
+ gem "better_auth-stripe"
11
+ ```
12
+
13
+ ```ruby
14
+ require "better_auth/stripe"
15
+
16
+ auth = BetterAuth.auth(
17
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
18
+ database: :memory,
19
+ plugins: [
20
+ BetterAuth::Plugins.stripe(
21
+ stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
22
+ stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET")
23
+ )
24
+ ]
25
+ )
26
+ ```
27
+
28
+ ## Notes
29
+
30
+ 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
+
32
+ Pass `stripe_client:` when you need a custom Stripe client, Stripe Connect behavior, or a test double.
@@ -0,0 +1,958 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "stripe"
5
+
6
+ module BetterAuth
7
+ module Stripe
8
+ class ClientAdapter
9
+ attr_reader :customers, :checkout, :billing_portal, :subscriptions, :prices, :webhooks
10
+
11
+ def initialize(api_key)
12
+ client = ::Stripe::StripeClient.new(api_key)
13
+ @customers = ResourceAdapter.new(client.v1.customers)
14
+ @checkout = NamespaceAdapter.new(sessions: ResourceAdapter.new(client.v1.checkout.sessions))
15
+ @billing_portal = NamespaceAdapter.new(sessions: ResourceAdapter.new(client.v1.billing_portal.sessions))
16
+ @subscriptions = ResourceAdapter.new(client.v1.subscriptions)
17
+ @prices = ResourceAdapter.new(client.v1.prices)
18
+ @webhooks = WebhooksAdapter.new
19
+ end
20
+ end
21
+
22
+ class NamespaceAdapter
23
+ def initialize(resources)
24
+ resources.each do |name, resource|
25
+ instance_variable_set(:"@#{name}", resource)
26
+ self.class.attr_reader(name) unless respond_to?(name)
27
+ end
28
+ end
29
+ end
30
+
31
+ class ResourceAdapter
32
+ def initialize(resource)
33
+ @resource = resource
34
+ end
35
+
36
+ def create(params = {}, options = nil)
37
+ options ? @resource.create(params || {}, options) : @resource.create(params || {})
38
+ end
39
+
40
+ def list(params = {})
41
+ @resource.list(params || {})
42
+ end
43
+
44
+ def search(params = {})
45
+ @resource.search(params || {})
46
+ end
47
+
48
+ def retrieve(id)
49
+ @resource.retrieve(id)
50
+ end
51
+
52
+ def update(id, params = {})
53
+ @resource.update(id, params || {})
54
+ end
55
+ end
56
+
57
+ class WebhooksAdapter
58
+ def construct_event(payload, signature, secret)
59
+ ::Stripe::Webhook.construct_event(payload, signature, secret)
60
+ end
61
+
62
+ def construct_event_async(payload, signature, secret)
63
+ construct_event(payload, signature, secret)
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ module BetterAuth
70
+ module Plugins
71
+ singleton_class.remove_method(:stripe) if singleton_class.method_defined?(:stripe)
72
+ remove_method(:stripe) if method_defined?(:stripe) || private_method_defined?(:stripe)
73
+ remove_const(:STRIPE_ERROR_CODES) if const_defined?(:STRIPE_ERROR_CODES, false)
74
+
75
+ module_function
76
+
77
+ STRIPE_ERROR_CODES = {
78
+ "UNAUTHORIZED" => "Unauthorized access",
79
+ "EMAIL_VERIFICATION_REQUIRED" => "Email verification required",
80
+ "SUBSCRIPTION_NOT_FOUND" => "Subscription not found",
81
+ "SUBSCRIPTION_PLAN_NOT_FOUND" => "Subscription plan not found",
82
+ "ALREADY_SUBSCRIBED_PLAN" => "You're already subscribed to this plan",
83
+ "REFERENCE_ID_NOT_ALLOWED" => "Reference id is not allowed",
84
+ "CUSTOMER_NOT_FOUND" => "Stripe customer not found for this user",
85
+ "UNABLE_TO_CREATE_CUSTOMER" => "Unable to create customer",
86
+ "UNABLE_TO_CREATE_BILLING_PORTAL" => "Unable to create billing portal session",
87
+ "ORGANIZATION_NOT_FOUND" => "Organization not found",
88
+ "ORGANIZATION_SUBSCRIPTION_NOT_ENABLED" => "Organization subscription is not enabled",
89
+ "AUTHORIZE_REFERENCE_REQUIRED" => "Organization subscriptions require authorizeReference callback to be configured",
90
+ "ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION" => "Cannot delete organization with active subscription",
91
+ "ORGANIZATION_REFERENCE_ID_REQUIRED" => "Reference ID is required. Provide referenceId or set activeOrganizationId in session",
92
+ "SUBSCRIPTION_NOT_ACTIVE" => "Subscription is not active",
93
+ "SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION" => "Subscription is not scheduled for cancellation",
94
+ "STRIPE_SIGNATURE_NOT_FOUND" => "Stripe signature not found",
95
+ "STRIPE_WEBHOOK_SECRET_NOT_FOUND" => "Stripe webhook secret not found",
96
+ "FAILED_TO_CONSTRUCT_STRIPE_EVENT" => "Failed to construct Stripe event",
97
+ "STRIPE_WEBHOOK_ERROR" => "Stripe webhook error",
98
+ "INVALID_REQUEST_BODY" => "Invalid request body"
99
+ }.freeze
100
+
101
+ def stripe(options = {})
102
+ config = normalize_hash(options)
103
+ Plugin.new(
104
+ id: "stripe",
105
+ init: ->(ctx) { {context: {schema: Schema.auth_tables(ctx.options)}} },
106
+ schema: stripe_schema(config),
107
+ endpoints: stripe_endpoints(config),
108
+ error_codes: STRIPE_ERROR_CODES,
109
+ options: config.merge(database_hooks: stripe_database_hooks(config), organization_hooks: stripe_organization_hooks(config))
110
+ )
111
+ end
112
+
113
+ def stripe_schema(config)
114
+ schema = {
115
+ user: {
116
+ fields: {
117
+ stripeCustomerId: {type: "string", required: false}
118
+ }
119
+ }
120
+ }
121
+ if config.dig(:subscription, :enabled)
122
+ schema[:subscription] = {
123
+ fields: {
124
+ plan: {type: "string", required: true},
125
+ referenceId: {type: "string", required: true},
126
+ stripeCustomerId: {type: "string", required: false},
127
+ stripeSubscriptionId: {type: "string", required: false},
128
+ status: {type: "string", required: false, default_value: "incomplete"},
129
+ periodStart: {type: "date", required: false},
130
+ periodEnd: {type: "date", required: false},
131
+ trialStart: {type: "date", required: false},
132
+ trialEnd: {type: "date", required: false},
133
+ cancelAtPeriodEnd: {type: "boolean", required: false, default_value: false},
134
+ cancelAt: {type: "date", required: false},
135
+ canceledAt: {type: "date", required: false},
136
+ endedAt: {type: "date", required: false},
137
+ seats: {type: "number", required: false},
138
+ limits: {type: "json", required: false}
139
+ }
140
+ }
141
+ end
142
+ if config.dig(:organization, :enabled)
143
+ schema[:organization] = {fields: {stripeCustomerId: {type: "string", required: false}}}
144
+ end
145
+ schema
146
+ end
147
+
148
+ def stripe_endpoints(config)
149
+ endpoints = {stripe_webhook: stripe_webhook_endpoint(config)}
150
+ return endpoints unless config.dig(:subscription, :enabled)
151
+
152
+ endpoints.merge(
153
+ upgrade_subscription: stripe_upgrade_subscription_endpoint(config),
154
+ cancel_subscription_callback: stripe_cancel_callback_endpoint(config),
155
+ cancel_subscription: stripe_cancel_subscription_endpoint(config),
156
+ restore_subscription: stripe_restore_subscription_endpoint(config),
157
+ list_active_subscriptions: stripe_list_subscriptions_endpoint(config),
158
+ subscription_success: stripe_success_endpoint(config),
159
+ create_billing_portal: stripe_billing_portal_endpoint(config)
160
+ )
161
+ end
162
+
163
+ def stripe_database_hooks(config)
164
+ return {} unless config[:create_customer_on_sign_up]
165
+
166
+ {
167
+ user: {
168
+ create: {
169
+ before: lambda do |data, hook_ctx|
170
+ next unless data["email"] && !data["stripeCustomerId"]
171
+
172
+ data["id"] ||= SecureRandom.hex(16)
173
+ customer = stripe_find_or_create_user_customer(config, data, nil, hook_ctx)
174
+ {data: {id: data["id"], stripeCustomerId: stripe_id(customer)}}
175
+ end
176
+ },
177
+ update: {
178
+ after: lambda do |user, _ctx|
179
+ next unless user && user["stripeCustomerId"]
180
+
181
+ customer = stripe_client(config).customers.retrieve(user["stripeCustomerId"])
182
+ next if stripe_fetch(customer, "deleted")
183
+ next if stripe_fetch(customer, "email") == user["email"]
184
+
185
+ stripe_client(config).customers.update(user["stripeCustomerId"], email: user["email"])
186
+ rescue
187
+ nil
188
+ end
189
+ }
190
+ }
191
+ }
192
+ end
193
+
194
+ def stripe_organization_hooks(config)
195
+ return {} unless config.dig(:organization, :enabled)
196
+
197
+ {
198
+ after_update_organization: lambda do |data, _ctx|
199
+ organization = data[:organization] || data["organization"]
200
+ next unless organization && organization["stripeCustomerId"]
201
+
202
+ customer = stripe_client(config).customers.retrieve(organization["stripeCustomerId"])
203
+ next if stripe_fetch(customer, "deleted")
204
+ next if stripe_fetch(customer, "name") == organization["name"]
205
+
206
+ stripe_client(config).customers.update(organization["stripeCustomerId"], name: organization["name"])
207
+ rescue
208
+ nil
209
+ end,
210
+ before_delete_organization: lambda do |data, _ctx|
211
+ organization = data[:organization] || data["organization"]
212
+ next unless organization && organization["stripeCustomerId"]
213
+
214
+ subscriptions = stripe_client(config).subscriptions.list(customer: organization["stripeCustomerId"], status: "all", limit: 100)
215
+ active = Array(stripe_fetch(subscriptions, "data")).any? do |subscription|
216
+ !%w[canceled incomplete incomplete_expired].include?(stripe_fetch(subscription, "status").to_s)
217
+ end
218
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION")) if active
219
+ end
220
+ }
221
+ end
222
+
223
+ def stripe_upgrade_subscription_endpoint(config)
224
+ Endpoint.new(path: "/subscription/upgrade", method: "POST") do |ctx|
225
+ session = Routes.current_session(ctx)
226
+ body = normalize_hash(ctx.body)
227
+ subscription_options = stripe_subscription_options(config)
228
+ customer_type = (body[:customer_type] || "user").to_s
229
+ reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
230
+ stripe_authorize_reference!(ctx, session, reference_id, "upgrade-subscription", customer_type, subscription_options, explicit: body.key?(:reference_id))
231
+
232
+ user = session.fetch(:user)
233
+ if subscription_options[:require_email_verification] && !user["emailVerified"]
234
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED"))
235
+ end
236
+
237
+ plan = stripe_plan_by_name(config, body[:plan])
238
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_PLAN_NOT_FOUND")) unless plan
239
+
240
+ subscription_to_update = nil
241
+ if body[:subscription_id]
242
+ subscription_to_update = ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: body[:subscription_id]}])
243
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription_to_update && subscription_to_update["referenceId"] == reference_id
244
+ end
245
+
246
+ customer_id = if customer_type == "organization"
247
+ subscription_to_update&.fetch("stripeCustomerId", nil) || stripe_organization_customer(config, ctx, reference_id, body[:metadata])
248
+ else
249
+ subscription_to_update&.fetch("stripeCustomerId", nil) || user["stripeCustomerId"] || stripe_create_customer(config, ctx, user, body[:metadata])
250
+ end
251
+
252
+ subscriptions = subscription_to_update ? [subscription_to_update] : ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}])
253
+ active_or_trialing = subscriptions.find { |entry| stripe_active_or_trialing?(entry) }
254
+ active_stripe_subscriptions = stripe_active_subscriptions(config, customer_id)
255
+ active_stripe = active_stripe_subscriptions.find do |entry|
256
+ if subscription_to_update&.fetch("stripeSubscriptionId", nil) || body[:subscription_id]
257
+ stripe_fetch(entry, "id") == subscription_to_update&.fetch("stripeSubscriptionId", nil) || stripe_fetch(entry, "id") == body[:subscription_id]
258
+ elsif active_or_trialing && active_or_trialing["stripeSubscriptionId"]
259
+ stripe_fetch(entry, "id") == active_or_trialing["stripeSubscriptionId"]
260
+ else
261
+ false
262
+ end
263
+ end
264
+
265
+ price_id = stripe_price_id(config, plan, body[:annual])
266
+ raise APIError.new("BAD_REQUEST", message: "Price ID not found for the selected plan") if price_id.to_s.empty?
267
+
268
+ active_stripe_item = stripe_subscription_item(active_stripe || {})
269
+ stripe_price_id_value = stripe_fetch(stripe_fetch(active_stripe_item || {}, "price") || {}, "id")
270
+ 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
272
+ same_price = !active_stripe || stripe_price_id_value == price_id
273
+ valid_period = !active_or_trialing || !active_or_trialing["periodEnd"] || active_or_trialing["periodEnd"] > Time.now
274
+ if active_or_trialing&.fetch("status", nil) == "active" && same_plan && same_seats && same_price && valid_period
275
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ALREADY_SUBSCRIBED_PLAN"))
276
+ end
277
+
278
+ if active_stripe
279
+ portal = stripe_client(config).billing_portal.sessions.create(
280
+ customer: customer_id,
281
+ return_url: stripe_url(ctx, body[:return_url] || "/"),
282
+ flow_data: {
283
+ type: "subscription_update_confirm",
284
+ after_completion: {type: "redirect", redirect: {return_url: stripe_url(ctx, body[:return_url] || "/")}},
285
+ subscription_update_confirm: {
286
+ subscription: stripe_fetch(active_stripe, "id"),
287
+ items: [{id: stripe_fetch(active_stripe_item || {}, "id"), quantity: body[:seats] || 1, price: price_id}]
288
+ }
289
+ }
290
+ )
291
+ next ctx.json(stripe_stringify_keys(portal).merge(redirect: stripe_redirect?(body)))
292
+ end
293
+
294
+ incomplete = subscriptions.find { |entry| entry["status"] == "incomplete" }
295
+ subscription = active_or_trialing || incomplete
296
+ if subscription
297
+ update = {plan: plan[:name].to_s.downcase, seats: body[:seats] || 1}
298
+ 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
+ else
300
+ subscription = ctx.context.adapter.create(
301
+ 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]}
303
+ )
304
+ end
305
+
306
+ has_ever_trialed = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).any? do |entry|
307
+ entry["trialStart"] || entry["trialEnd"] || entry["status"] == "trialing"
308
+ end
309
+ free_trial = (!has_ever_trialed && plan[:free_trial]) ? {trial_period_days: plan.dig(:free_trial, :days)} : {}
310
+ checkout_customization = subscription_options[:get_checkout_session_params]&.call(
311
+ {user: user, session: session.fetch(:session), plan: plan, subscription: subscription},
312
+ ctx.request,
313
+ ctx
314
+ ) || {}
315
+ custom_params = stripe_fetch(checkout_customization, "params") || {}
316
+ custom_options = normalize_hash(stripe_fetch(checkout_customization, "options") || {})
317
+ custom_subscription_data = stripe_fetch(custom_params, "subscription_data") || stripe_fetch(custom_params, "subscriptionData") || {}
318
+ internal_metadata = {userId: user.fetch("id"), subscriptionId: subscription.fetch("id"), referenceId: reference_id}
319
+ metadata = stripe_subscription_metadata_set(internal_metadata, body[:metadata], stripe_fetch(custom_params, "metadata"))
320
+ subscription_metadata = stripe_subscription_metadata_set(internal_metadata, body[:metadata], stripe_fetch(custom_subscription_data, "metadata"))
321
+ checkout_params = stripe_deep_merge(
322
+ custom_params,
323
+ customer: customer_id,
324
+ customer_update: (customer_type == "user") ? {name: "auto", address: "auto"} : {address: "auto"},
325
+ 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"))}"),
327
+ cancel_url: stripe_url(ctx, body[:cancel_url] || "/"),
328
+ line_items: [{price: price_id, quantity: body[:seats] || 1}],
329
+ subscription_data: free_trial.merge(metadata: subscription_metadata),
330
+ mode: "subscription",
331
+ client_reference_id: reference_id,
332
+ metadata: metadata
333
+ )
334
+ checkout_params[:metadata] = metadata
335
+ checkout_params[:subscription_data] ||= {}
336
+ checkout_params[:subscription_data][:metadata] = subscription_metadata
337
+ checkout = stripe_client(config).checkout.sessions.create(checkout_params, custom_options.empty? ? nil : custom_options)
338
+ ctx.json(stripe_stringify_keys(checkout).merge(redirect: stripe_redirect?(body)))
339
+ end
340
+ end
341
+
342
+ def stripe_cancel_subscription_endpoint(config)
343
+ Endpoint.new(path: "/subscription/cancel", method: "POST") do |ctx|
344
+ session = Routes.current_session(ctx)
345
+ body = normalize_hash(ctx.body)
346
+ customer_type = (body[:customer_type] || "user").to_s
347
+ reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
348
+ stripe_authorize_reference!(ctx, session, reference_id, "cancel-subscription", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
349
+ subscription = stripe_find_subscription_for_action(ctx, reference_id, body[:subscription_id], active_only: true)
350
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription && subscription["stripeCustomerId"]
351
+
352
+ active = stripe_active_subscriptions(config, subscription["stripeCustomerId"])
353
+ if active.empty?
354
+ ctx.context.adapter.delete_many(model: "subscription", where: [{field: "referenceId", value: reference_id}])
355
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND"))
356
+ end
357
+ stripe_subscription = active.find { |entry| stripe_fetch(entry, "id") == subscription["stripeSubscriptionId"] }
358
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless stripe_subscription
359
+
360
+ portal = stripe_client(config).billing_portal.sessions.create(
361
+ customer: subscription["stripeCustomerId"],
362
+ return_url: stripe_url(ctx, "#{ctx.context.base_url}/subscription/cancel/callback?callbackURL=#{Rack::Utils.escape(body[:return_url] || "/")}&subscriptionId=#{Rack::Utils.escape(subscription.fetch("id"))}"),
363
+ flow_data: {type: "subscription_cancel", subscription_cancel: {subscription: stripe_fetch(stripe_subscription, "id")}}
364
+ )
365
+ ctx.json(stripe_stringify_keys(portal).merge(redirect: stripe_redirect?(body)))
366
+ rescue APIError
367
+ raise
368
+ rescue => error
369
+ if error.message.include?("already set to be canceled") && subscription && !stripe_pending_cancel?(subscription)
370
+ stripe_sub = stripe_client(config).subscriptions.retrieve(subscription["stripeSubscriptionId"])
371
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: stripe_subscription_state(stripe_sub, include_status: false))
372
+ end
373
+ raise APIError.new("BAD_REQUEST", message: error.message)
374
+ end
375
+ end
376
+
377
+ def stripe_restore_subscription_endpoint(config)
378
+ Endpoint.new(path: "/subscription/restore", method: "POST") do |ctx|
379
+ session = Routes.current_session(ctx)
380
+ body = normalize_hash(ctx.body)
381
+ customer_type = (body[:customer_type] || "user").to_s
382
+ reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
383
+ stripe_authorize_reference!(ctx, session, reference_id, "restore-subscription", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
384
+ subscription = stripe_find_subscription_for_action(ctx, reference_id, body[:subscription_id], active_only: false)
385
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription && subscription["stripeCustomerId"]
386
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_ACTIVE")) unless stripe_active_or_trialing?(subscription)
387
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION")) unless stripe_pending_cancel?(subscription)
388
+
389
+ active = stripe_active_subscriptions(config, subscription["stripeCustomerId"]).first
390
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless active
391
+
392
+ update_params = if stripe_fetch(active, "cancel_at")
393
+ {cancel_at: ""}
394
+ elsif stripe_fetch(active, "cancel_at_period_end")
395
+ {cancel_at_period_end: false}
396
+ else
397
+ {}
398
+ end
399
+ restored = stripe_client(config).subscriptions.update(stripe_fetch(active, "id"), update_params)
400
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {cancelAtPeriodEnd: false, cancelAt: nil, canceledAt: nil})
401
+ ctx.json(stripe_stringify_keys(restored))
402
+ end
403
+ end
404
+
405
+ def stripe_list_subscriptions_endpoint(config)
406
+ Endpoint.new(path: "/subscription/list", method: "GET") do |ctx|
407
+ session = Routes.current_session(ctx)
408
+ query = normalize_hash(ctx.query)
409
+ customer_type = (query[:customer_type] || "user").to_s
410
+ reference_id = stripe_reference_id!(ctx, session, customer_type, query[:reference_id], config)
411
+ stripe_authorize_reference!(ctx, session, reference_id, "list-subscription", customer_type, stripe_subscription_options(config), explicit: query.key?(:reference_id))
412
+ plans = stripe_plans(config)
413
+ subscriptions = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).select { |entry| stripe_active_or_trialing?(entry) }
414
+ ctx.json(subscriptions.map do |entry|
415
+ 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))
417
+ end)
418
+ end
419
+ end
420
+
421
+ def stripe_billing_portal_endpoint(config)
422
+ Endpoint.new(path: "/subscription/billing-portal", method: "POST") do |ctx|
423
+ session = Routes.current_session(ctx)
424
+ body = normalize_hash(ctx.body)
425
+ customer_type = (body[:customer_type] || "user").to_s
426
+ reference_id = stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
427
+ stripe_authorize_reference!(ctx, session, reference_id, "billing-portal", customer_type, stripe_subscription_options(config), explicit: body.key?(:reference_id))
428
+ customer_id = if customer_type == "organization"
429
+ org = ctx.context.adapter.find_one(model: "organization", where: [{field: "id", value: reference_id}])
430
+ org&.fetch("stripeCustomerId", nil) || stripe_active_subscription(ctx, reference_id)&.fetch("stripeCustomerId", nil)
431
+ else
432
+ session.fetch(:user)["stripeCustomerId"] || stripe_active_subscription(ctx, reference_id)&.fetch("stripeCustomerId", nil)
433
+ end
434
+ raise APIError.new("NOT_FOUND", message: STRIPE_ERROR_CODES.fetch("CUSTOMER_NOT_FOUND")) unless customer_id
435
+
436
+ portal = stripe_client(config).billing_portal.sessions.create(customer: customer_id, return_url: stripe_url(ctx, body[:return_url] || "/"), locale: body[:locale])
437
+ ctx.json(stripe_stringify_keys(portal).merge(redirect: stripe_redirect?(body)))
438
+ rescue APIError
439
+ raise
440
+ rescue
441
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: STRIPE_ERROR_CODES.fetch("UNABLE_TO_CREATE_BILLING_PORTAL"))
442
+ end
443
+ end
444
+
445
+ def stripe_cancel_callback_endpoint(config = nil)
446
+ Endpoint.new(path: "/subscription/cancel/callback", method: "GET") do |ctx|
447
+ query = normalize_hash(ctx.query)
448
+ callback = query[:callback_url] || "/"
449
+ unless query[:subscription_id]
450
+ raise ctx.redirect(stripe_url(ctx, callback))
451
+ end
452
+ session = Routes.current_session(ctx, allow_nil: true)
453
+ raise ctx.redirect(stripe_url(ctx, callback)) unless session
454
+
455
+ subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: query[:subscription_id]}])
456
+ if subscription && !stripe_pending_cancel?(subscription) && subscription["stripeCustomerId"]
457
+ current = stripe_active_subscriptions(config || {}, subscription["stripeCustomerId"]).find { |entry| stripe_fetch(entry, "id") == subscription["stripeSubscriptionId"] }
458
+ if current && stripe_stripe_pending_cancel?(current)
459
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: stripe_subscription_state(current, include_status: true))
460
+ stripe_subscription_options(config || {})[:on_subscription_cancel]&.call({subscription: subscription, stripeSubscription: current, stripe_subscription: current, cancellationDetails: stripe_fetch(current, "cancellation_details"), cancellation_details: stripe_fetch(current, "cancellation_details"), event: nil})
461
+ end
462
+ end
463
+ raise ctx.redirect(stripe_url(ctx, callback))
464
+ end
465
+ end
466
+
467
+ def stripe_success_endpoint(config = nil)
468
+ Endpoint.new(path: "/subscription/success", method: "GET") do |ctx|
469
+ query = normalize_hash(ctx.query)
470
+ callback = query[:callback_url] || "/"
471
+ unless query[:subscription_id]
472
+ raise ctx.redirect(stripe_url(ctx, callback))
473
+ end
474
+ session = Routes.current_session(ctx, allow_nil: true)
475
+ raise ctx.redirect(stripe_url(ctx, callback)) unless session
476
+
477
+ subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: query[:subscription_id]}])
478
+ raise ctx.redirect(stripe_url(ctx, callback)) unless subscription
479
+ raise ctx.redirect(stripe_url(ctx, callback)) if stripe_active_or_trialing?(subscription)
480
+
481
+ customer_id = subscription["stripeCustomerId"] || session.fetch(:user)["stripeCustomerId"]
482
+ raise ctx.redirect(stripe_url(ctx, callback)) unless customer_id
483
+
484
+ stripe_subscription = stripe_active_subscriptions(config || {}, customer_id).first
485
+ 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"))
488
+ if item && plan
489
+ ctx.context.adapter.update(
490
+ model: "subscription",
491
+ where: [{field: "id", value: subscription.fetch("id")}],
492
+ update: stripe_subscription_state(stripe_subscription, include_status: true).merge(
493
+ plan: plan[:name].to_s.downcase,
494
+ seats: stripe_fetch(item, "quantity") || 1,
495
+ stripeSubscriptionId: stripe_fetch(stripe_subscription, "id")
496
+ )
497
+ )
498
+ end
499
+ end
500
+ raise ctx.redirect(stripe_url(ctx, callback))
501
+ end
502
+ end
503
+
504
+ def stripe_webhook_endpoint(config)
505
+ Endpoint.new(path: "/stripe/webhook", method: "POST") do |ctx|
506
+ signature = ctx.headers["stripe-signature"]
507
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("STRIPE_SIGNATURE_NOT_FOUND")) if signature.to_s.empty?
508
+
509
+ 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
+
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])
515
+ else
516
+ webhooks.construct_event(ctx.body, signature, config[:stripe_webhook_secret])
517
+ end
518
+ else
519
+ ctx.body
520
+ end
521
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT")) unless event
522
+ stripe_handle_event(ctx, event)
523
+ 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
+ end
529
+ end
530
+
531
+ def stripe_handle_event(ctx, event)
532
+ event = normalize_hash(event)
533
+ type = event[:type].to_s
534
+ case type
535
+ when "checkout.session.completed"
536
+ stripe_on_checkout_completed(ctx, event)
537
+ when "customer.subscription.created"
538
+ stripe_on_subscription_created(ctx, event)
539
+ when "customer.subscription.updated"
540
+ stripe_on_subscription_updated(ctx, event)
541
+ when "customer.subscription.deleted"
542
+ stripe_on_subscription_deleted(ctx, event)
543
+ end
544
+ config = ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
545
+ config[:on_event]&.call(event)
546
+ end
547
+
548
+ def stripe_on_checkout_completed(ctx, event)
549
+ config = ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
550
+ object = normalize_hash(event.dig(:data, :object) || {})
551
+ return if object[:mode] == "setup" || !config.dig(:subscription, :enabled)
552
+
553
+ stripe_subscription = stripe_client(config).subscriptions.retrieve(object[:subscription])
554
+ item = stripe_subscription_item(stripe_subscription)
555
+ return unless item
556
+
557
+ plan = stripe_plan_by_price_info(config, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key"))
558
+ metadata = normalize_hash(object[:metadata] || {})
559
+ reference_id = object[:client_reference_id] || metadata[:reference_id]
560
+ subscription_id = metadata[:subscription_id]
561
+ return unless plan && reference_id && subscription_id
562
+
563
+ update = stripe_subscription_state(stripe_subscription, include_status: true).merge(
564
+ plan: plan[:name].to_s.downcase,
565
+ stripeSubscriptionId: object[:subscription],
566
+ seats: stripe_fetch(item, "quantity"),
567
+ trialStart: stripe_time(stripe_fetch(stripe_subscription, "trial_start")),
568
+ trialEnd: stripe_time(stripe_fetch(stripe_subscription, "trial_end"))
569
+ ).compact
570
+ db_subscription = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription_id}], update: update)
571
+ plan.dig(:free_trial, :on_trial_start)&.call(db_subscription) if db_subscription && update[:trialStart]
572
+ callback = config.dig(:subscription, :on_subscription_complete)
573
+ callback&.call({event: event, subscription: db_subscription, stripeSubscription: stripe_subscription, stripe_subscription: stripe_subscription, plan: plan}, ctx)
574
+ end
575
+
576
+ def stripe_on_subscription_created(ctx, event)
577
+ config = ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
578
+ object = normalize_hash(event.dig(:data, :object) || {})
579
+ customer_id = object[:customer].to_s
580
+ return if customer_id.empty?
581
+ metadata = normalize_hash(object[:metadata] || {})
582
+ existing = if metadata[:subscription_id]
583
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: metadata[:subscription_id]}])
584
+ else
585
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
586
+ end
587
+ return if existing
588
+
589
+ 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
+ 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]))
594
+ return unless plan
595
+
596
+ created = ctx.context.adapter.create(
597
+ model: "subscription",
598
+ data: stripe_subscription_state(object, include_status: true).merge(
599
+ referenceId: reference.fetch(:reference_id),
600
+ stripeCustomerId: customer_id,
601
+ stripeSubscriptionId: object[:id],
602
+ plan: plan[:name].to_s.downcase,
603
+ seats: stripe_fetch(item, "quantity"),
604
+ limits: plan[:limits]
605
+ ).compact
606
+ )
607
+ config.dig(:subscription, :on_subscription_created)&.call({event: event, subscription: created, stripeSubscription: object, stripe_subscription: object, plan: plan})
608
+ end
609
+
610
+ def stripe_on_subscription_updated(ctx, event)
611
+ config = ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
612
+ object = normalize_hash(event.dig(:data, :object) || {})
613
+ item = stripe_subscription_item(object)
614
+ return unless item
615
+
616
+ metadata = normalize_hash(object[:metadata] || {})
617
+ subscription = if metadata[:subscription_id]
618
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: metadata[:subscription_id]}])
619
+ else
620
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
621
+ end
622
+ unless subscription
623
+ candidates = ctx.context.adapter.find_many(model: "subscription", where: [{field: "stripeCustomerId", value: object[:customer]}])
624
+ subscription = if candidates.length > 1
625
+ candidates.find { |entry| stripe_active_or_trialing?(entry) }
626
+ else
627
+ candidates.first
628
+ end
629
+ end
630
+ return unless subscription
631
+
632
+ plan = stripe_plan_by_price_info(config, stripe_fetch(stripe_fetch(item, "price") || {}, "id"), stripe_fetch(stripe_fetch(item, "price") || {}, "lookup_key"))
633
+ was_pending = stripe_pending_cancel?(subscription)
634
+ update = stripe_subscription_state(object, include_status: true).merge(
635
+ stripeSubscriptionId: object[:id],
636
+ seats: stripe_fetch(item, "quantity")
637
+ )
638
+ update[:plan] = plan[:name].to_s.downcase if plan
639
+ 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)
641
+ if object[:status] == "active" && stripe_stripe_pending_cancel?(object) && !was_pending
642
+ 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
+ end
644
+ config.dig(:subscription, :on_subscription_update)&.call({event: event, subscription: updated || subscription})
645
+ end
646
+
647
+ def stripe_on_subscription_deleted(ctx, event)
648
+ config = ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
649
+ object = normalize_hash(event.dig(:data, :object) || {})
650
+ subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
651
+ return unless subscription
652
+
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"))
654
+ config.dig(:subscription, :on_subscription_deleted)&.call({event: event, subscription: subscription, stripeSubscription: object, stripe_subscription: object})
655
+ end
656
+
657
+ def stripe_create_customer(config, ctx, user, metadata = nil)
658
+ customer = stripe_find_or_create_user_customer(config, user, metadata, ctx)
659
+ id = stripe_id(customer)
660
+ ctx.context.internal_adapter.update_user(user.fetch("id"), stripeCustomerId: id)
661
+ id
662
+ end
663
+
664
+ 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
667
+ if customer
668
+ stripe_notify_customer_created(config, customer, user, ctx)
669
+ return customer
670
+ end
671
+
672
+ raw_extra = config[:get_customer_create_params]&.call(user, ctx) || {}
673
+ extra_metadata = stripe_fetch(raw_extra, "metadata")
674
+ extra = normalize_hash(raw_extra)
675
+ params = stripe_deep_merge(
676
+ extra,
677
+ email: user["email"],
678
+ name: user["name"],
679
+ metadata: stripe_customer_metadata_set({userId: user["id"], customerType: "user"}, metadata, extra_metadata)
680
+ )
681
+ params[:metadata] = stripe_customer_metadata_set({userId: user["id"], customerType: "user"}, metadata, extra_metadata)
682
+ customer = stripe_client(config).customers.create(params)
683
+ stripe_notify_customer_created(config, customer, user, ctx)
684
+ customer
685
+ end
686
+
687
+ def stripe_organization_customer(config, ctx, organization_id, metadata = nil)
688
+ raise APIError.new("BAD_REQUEST", message: "Organization integration requires the organization plugin") unless config.dig(:organization, :enabled)
689
+
690
+ org = ctx.context.adapter.find_one(model: "organization", where: [{field: "id", value: organization_id}])
691
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless org
692
+ return org["stripeCustomerId"] if org["stripeCustomerId"]
693
+
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
696
+ unless customer
697
+ raw_extra = config.dig(:organization, :get_customer_create_params)&.call(org, ctx) || {}
698
+ extra_metadata = stripe_fetch(raw_extra, "metadata")
699
+ extra = normalize_hash(raw_extra)
700
+ params = stripe_deep_merge(
701
+ extra,
702
+ name: org["name"],
703
+ metadata: stripe_customer_metadata_set({organizationId: org["id"], customerType: "organization"}, metadata, extra_metadata)
704
+ )
705
+ params[:metadata] = stripe_customer_metadata_set({organizationId: org["id"], customerType: "organization"}, metadata, extra_metadata)
706
+ customer = stripe_client(config).customers.create(params)
707
+ config.dig(:organization, :on_customer_create)&.call({stripeCustomer: customer, stripe_customer: customer, organization: org.merge("stripeCustomerId" => stripe_id(customer))}, ctx)
708
+ end
709
+ ctx.context.adapter.update(model: "organization", where: [{field: "id", value: org.fetch("id")}], update: {stripeCustomerId: stripe_id(customer)})
710
+ stripe_id(customer)
711
+ rescue APIError
712
+ raise
713
+ rescue
714
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("UNABLE_TO_CREATE_CUSTOMER"))
715
+ end
716
+
717
+ def stripe_client(config)
718
+ injected = config[:stripe_client] || config[:client]
719
+ return injected if injected
720
+ return config[:_stripe_client_adapter] if config[:_stripe_client_adapter]
721
+
722
+ api_key = config[:stripe_api_key] || ENV["STRIPE_SECRET_KEY"]
723
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Stripe client is required") if api_key.to_s.empty?
724
+
725
+ config[:_stripe_client_adapter] = BetterAuth::Stripe::ClientAdapter.new(api_key)
726
+ end
727
+
728
+ def stripe_id(object)
729
+ stripe_fetch(object, "id")
730
+ end
731
+
732
+ def stripe_fetch(object, key)
733
+ return nil unless object.respond_to?(:[])
734
+
735
+ object[key] || object[key.to_sym]
736
+ end
737
+
738
+ def stripe_time(value)
739
+ return nil unless value
740
+
741
+ Time.at(value.to_i)
742
+ end
743
+
744
+ def stripe_subscription_options(config)
745
+ normalize_hash(config[:subscription] || {})
746
+ end
747
+
748
+ def stripe_plans(config)
749
+ plans = stripe_subscription_options(config)[:plans] || []
750
+ plans = plans.call if plans.respond_to?(:call)
751
+ Array(plans).map { |plan| normalize_hash(plan) }
752
+ end
753
+
754
+ def stripe_plan_by_name(config, name)
755
+ stripe_plans(config).find { |plan| plan[:name].to_s.downcase == name.to_s.downcase }
756
+ end
757
+
758
+ def stripe_plan_by_price_info(config, price_id, lookup_key = nil)
759
+ stripe_plans(config).find do |plan|
760
+ plan[:price_id] == price_id || plan[:annual_discount_price_id] == price_id || (lookup_key && (plan[:lookup_key] == lookup_key || plan[:annual_discount_lookup_key] == lookup_key))
761
+ end
762
+ end
763
+
764
+ def stripe_price_id(config, plan, annual = false)
765
+ annual ? (plan[:annual_discount_price_id] || stripe_resolve_lookup(config, plan[:annual_discount_lookup_key])) : (plan[:price_id] || stripe_resolve_lookup(config, plan[:lookup_key]))
766
+ end
767
+
768
+ def stripe_resolve_lookup(config, lookup_key)
769
+ return nil if lookup_key.to_s.empty?
770
+ return nil unless stripe_client(config).respond_to?(:prices)
771
+
772
+ prices = stripe_client(config).prices.list(lookup_keys: [lookup_key], active: true, limit: 1)
773
+ stripe_fetch(Array(stripe_fetch(prices, "data")).first || {}, "id")
774
+ end
775
+
776
+ def stripe_reference_id!(ctx, session, customer_type, explicit_reference_id, config)
777
+ return explicit_reference_id || session.fetch(:user).fetch("id") unless customer_type == "organization"
778
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("ORGANIZATION_SUBSCRIPTION_NOT_ENABLED")) unless config.dig(:organization, :enabled)
779
+
780
+ 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?
782
+ reference_id
783
+ end
784
+
785
+ def stripe_authorize_reference!(ctx, session, reference_id, action, customer_type, subscription_options, explicit: false)
786
+ callback = subscription_options[:authorize_reference]
787
+ if customer_type == "organization"
788
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("AUTHORIZE_REFERENCE_REQUIRED")) unless callback
789
+ elsif !explicit || reference_id == session.fetch(:user).fetch("id")
790
+ return
791
+ elsif !callback
792
+ raise APIError.new("BAD_REQUEST", message: STRIPE_ERROR_CODES.fetch("REFERENCE_ID_NOT_ALLOWED"))
793
+ end
794
+
795
+ allowed = callback.call({user: session.fetch(:user), session: session.fetch(:session), referenceId: reference_id, reference_id: reference_id, action: action}, ctx)
796
+ raise APIError.new("UNAUTHORIZED", message: STRIPE_ERROR_CODES.fetch("UNAUTHORIZED")) unless allowed
797
+ end
798
+
799
+ def stripe_find_subscription_for_action(ctx, reference_id, subscription_id, active_only:)
800
+ subscription = if subscription_id
801
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: subscription_id}])
802
+ else
803
+ ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).find { |entry| !active_only || stripe_active_or_trialing?(entry) }
804
+ end
805
+ return nil if subscription_id && subscription && subscription["referenceId"] != reference_id
806
+
807
+ subscription
808
+ end
809
+
810
+ def stripe_active_subscription(ctx, reference_id)
811
+ ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).find { |entry| stripe_active_or_trialing?(entry) }
812
+ end
813
+
814
+ def stripe_active_subscriptions(config, customer_id)
815
+ result = stripe_client(config).subscriptions.list(customer: customer_id)
816
+ Array(stripe_fetch(result, "data")).select { |entry| stripe_active_or_trialing?(entry) }
817
+ end
818
+
819
+ def stripe_active_or_trialing?(subscription)
820
+ %w[active trialing].include?(stripe_fetch(subscription, "status").to_s)
821
+ end
822
+
823
+ def stripe_pending_cancel?(subscription)
824
+ !!(stripe_fetch(subscription, "cancelAtPeriodEnd") || stripe_fetch(subscription, "cancelAt"))
825
+ end
826
+
827
+ def stripe_stripe_pending_cancel?(subscription)
828
+ !!(stripe_fetch(subscription, "cancel_at_period_end") || stripe_fetch(subscription, "cancel_at"))
829
+ end
830
+
831
+ def stripe_subscription_item(subscription)
832
+ Array(stripe_fetch(stripe_fetch(subscription, "items") || {}, "data")).first
833
+ end
834
+
835
+ def stripe_subscription_state(subscription, include_status: true)
836
+ item = stripe_subscription_item(subscription)
837
+ state = {
838
+ periodStart: stripe_time(stripe_fetch(item || subscription, "current_period_start")),
839
+ periodEnd: stripe_time(stripe_fetch(item || subscription, "current_period_end")),
840
+ cancelAtPeriodEnd: stripe_fetch(subscription, "cancel_at_period_end"),
841
+ cancelAt: stripe_time(stripe_fetch(subscription, "cancel_at")),
842
+ canceledAt: stripe_time(stripe_fetch(subscription, "canceled_at")),
843
+ endedAt: stripe_time(stripe_fetch(subscription, "ended_at")),
844
+ trialStart: stripe_time(stripe_fetch(subscription, "trial_start")),
845
+ trialEnd: stripe_time(stripe_fetch(subscription, "trial_end"))
846
+ }
847
+ state[:status] = stripe_fetch(subscription, "status") if include_status
848
+ state.compact
849
+ end
850
+
851
+ def stripe_reference_by_customer(ctx, config, customer_id)
852
+ if config.dig(:organization, :enabled)
853
+ org = ctx.context.adapter.find_one(model: "organization", where: [{field: "stripeCustomerId", value: customer_id}])
854
+ return {customer_type: "organization", reference_id: org.fetch("id")} if org
855
+ end
856
+ user = ctx.context.adapter.find_one(model: "user", where: [{field: "stripeCustomerId", value: customer_id}])
857
+ return {customer_type: "user", reference_id: user.fetch("id")} if user
858
+
859
+ nil
860
+ end
861
+
862
+ def stripe_metadata(internal, *user_metadata)
863
+ user_metadata.compact
864
+ .reduce({}) do |acc, entry|
865
+ next acc unless entry.respond_to?(:each)
866
+
867
+ acc.merge(entry.each_with_object({}) { |(key, value), result| result[stripe_metadata_key(key)] = value })
868
+ end
869
+ .merge(internal.transform_keys { |key| stripe_metadata_key(key) })
870
+ end
871
+
872
+ def stripe_customer_metadata_set(internal_fields, *user_metadata)
873
+ stripe_metadata(internal_fields, *user_metadata)
874
+ end
875
+
876
+ def stripe_customer_metadata_get(metadata)
877
+ {
878
+ userId: stripe_metadata_fetch(metadata, "userId"),
879
+ organizationId: stripe_metadata_fetch(metadata, "organizationId"),
880
+ customerType: stripe_metadata_fetch(metadata, "customerType")
881
+ }
882
+ end
883
+
884
+ def stripe_subscription_metadata_set(internal_fields, *user_metadata)
885
+ stripe_metadata(internal_fields, *user_metadata)
886
+ end
887
+
888
+ def stripe_subscription_metadata_get(metadata)
889
+ {
890
+ userId: stripe_metadata_fetch(metadata, "userId"),
891
+ subscriptionId: stripe_metadata_fetch(metadata, "subscriptionId"),
892
+ referenceId: stripe_metadata_fetch(metadata, "referenceId")
893
+ }
894
+ end
895
+
896
+ def stripe_notify_customer_created(config, customer, user, ctx)
897
+ config[:on_customer_create]&.call(
898
+ {
899
+ stripeCustomer: customer,
900
+ stripe_customer: customer,
901
+ user: user.merge("stripeCustomerId" => stripe_id(customer))
902
+ },
903
+ ctx
904
+ )
905
+ end
906
+
907
+ def stripe_metadata_key(key)
908
+ case normalize_key(key)
909
+ when :user_id then "userId"
910
+ when :organization_id then "organizationId"
911
+ when :customer_type then "customerType"
912
+ when :subscription_id then "subscriptionId"
913
+ when :reference_id then "referenceId"
914
+ else
915
+ key.to_s
916
+ end
917
+ end
918
+
919
+ def stripe_metadata_fetch(metadata, key)
920
+ return nil unless metadata.respond_to?(:[])
921
+
922
+ metadata[key] || metadata[key.to_sym] || metadata[normalize_key(key)] || metadata[normalize_key(key).to_s]
923
+ end
924
+
925
+ def stripe_deep_merge(base, override)
926
+ normalize_hash(base).merge(normalize_hash(override)) do |_key, old, new|
927
+ if old.is_a?(Hash) && new.is_a?(Hash)
928
+ stripe_deep_merge(old, new)
929
+ else
930
+ new
931
+ end
932
+ end
933
+ end
934
+
935
+ def stripe_redirect?(body)
936
+ body[:disable_redirect] != true
937
+ end
938
+
939
+ def stripe_stringify_keys(value)
940
+ return value unless value.is_a?(Hash)
941
+
942
+ value.each_with_object({}) do |(key, object), result|
943
+ result[key.to_s] = object
944
+ result[key.to_sym] = object
945
+ end
946
+ end
947
+
948
+ def stripe_url(ctx, url)
949
+ return url if url.to_s.match?(/\A[a-zA-Z][a-zA-Z0-9+\-.]*:/)
950
+
951
+ "#{ctx.context.base_url}#{url.to_s.start_with?("/") ? url : "/#{url}"}"
952
+ end
953
+
954
+ def stripe_escape_search(value)
955
+ value.to_s.gsub("\"", "\\\"")
956
+ end
957
+ end
958
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require_relative "stripe/version"
5
+ require_relative "plugins/stripe"
6
+
7
+ module BetterAuth
8
+ module Stripe
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_auth-stripe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Sala
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: better_auth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: stripe
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '13'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '20'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '13'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '20'
46
+ - !ruby/object:Gem::Dependency
47
+ name: bundler
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.5'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.5'
60
+ - !ruby/object:Gem::Dependency
61
+ name: minitest
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '5.25'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '5.25'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rake
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '13.2'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '13.2'
88
+ - !ruby/object:Gem::Dependency
89
+ name: standardrb
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '1.0'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '1.0'
102
+ description: Adds Stripe customer, subscription, checkout, billing portal, and webhook
103
+ routes for Better Auth Ruby.
104
+ email:
105
+ - sebastian.sala.tech@gmail.com
106
+ executables: []
107
+ extensions: []
108
+ extra_rdoc_files: []
109
+ files:
110
+ - CHANGELOG.md
111
+ - README.md
112
+ - lib/better_auth/plugins/stripe.rb
113
+ - lib/better_auth/stripe.rb
114
+ - lib/better_auth/stripe/version.rb
115
+ homepage: https://github.com/sebasxsala/better-auth
116
+ licenses:
117
+ - MIT
118
+ metadata:
119
+ homepage_uri: https://github.com/sebasxsala/better-auth
120
+ source_code_uri: https://github.com/sebasxsala/better-auth
121
+ changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-stripe/CHANGELOG.md
122
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 3.2.0
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 3.6.9
138
+ specification_version: 4
139
+ summary: Stripe plugin package for Better Auth Ruby
140
+ test_files: []