better_auth-stripe 0.2.1 → 0.6.2

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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stripe"
4
+
5
+ module BetterAuth
6
+ module Stripe
7
+ class ClientAdapter
8
+ attr_reader :customers, :checkout, :billing_portal, :subscriptions, :prices, :subscription_schedules, :webhooks
9
+
10
+ def initialize(api_key)
11
+ client = ::Stripe::StripeClient.new(api_key)
12
+ @customers = ResourceAdapter.new(client.v1.customers)
13
+ @checkout = NamespaceAdapter.new(sessions: ResourceAdapter.new(client.v1.checkout.sessions))
14
+ @billing_portal = NamespaceAdapter.new(sessions: ResourceAdapter.new(client.v1.billing_portal.sessions))
15
+ @subscriptions = ResourceAdapter.new(client.v1.subscriptions)
16
+ @prices = ResourceAdapter.new(client.v1.prices)
17
+ @subscription_schedules = ResourceAdapter.new(client.v1.subscription_schedules)
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
+
56
+ def release(id)
57
+ @resource.release(id)
58
+ end
59
+ end
60
+
61
+ class WebhooksAdapter
62
+ def construct_event(payload, signature, secret)
63
+ ::Stripe::Webhook.construct_event(payload, signature, secret)
64
+ end
65
+
66
+ def construct_event_async(payload, signature, secret)
67
+ construct_event(payload, signature, secret)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ remove_const(:ERROR_CODES) if const_defined?(:ERROR_CODES, false)
6
+
7
+ ERROR_CODES = {
8
+ "UNAUTHORIZED" => "Unauthorized access",
9
+ "EMAIL_VERIFICATION_REQUIRED" => "Email verification required",
10
+ "SUBSCRIPTION_NOT_FOUND" => "Subscription not found",
11
+ "SUBSCRIPTION_PLAN_NOT_FOUND" => "Subscription plan not found",
12
+ "ALREADY_SUBSCRIBED_PLAN" => "You're already subscribed to this plan",
13
+ "REFERENCE_ID_NOT_ALLOWED" => "Reference id is not allowed",
14
+ "CUSTOMER_NOT_FOUND" => "Stripe customer not found for this user",
15
+ "UNABLE_TO_CREATE_CUSTOMER" => "Unable to create customer",
16
+ "UNABLE_TO_CREATE_BILLING_PORTAL" => "Unable to create billing portal session",
17
+ "ORGANIZATION_NOT_FOUND" => "Organization not found",
18
+ "ORGANIZATION_SUBSCRIPTION_NOT_ENABLED" => "Organization subscription is not enabled",
19
+ "AUTHORIZE_REFERENCE_REQUIRED" => "Organization subscriptions require authorizeReference callback to be configured",
20
+ "ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION" => "Cannot delete organization with active subscription",
21
+ "ORGANIZATION_REFERENCE_ID_REQUIRED" => "Reference ID is required. Provide referenceId or set activeOrganizationId in session",
22
+ "SUBSCRIPTION_NOT_ACTIVE" => "Subscription is not active",
23
+ "SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION" => "Subscription is not scheduled for cancellation",
24
+ "STRIPE_SIGNATURE_NOT_FOUND" => "Stripe signature not found",
25
+ "STRIPE_WEBHOOK_SECRET_NOT_FOUND" => "Stripe webhook secret not found",
26
+ "FAILED_TO_CONSTRUCT_STRIPE_EVENT" => "Failed to construct Stripe event",
27
+ "STRIPE_WEBHOOK_ERROR" => "Stripe webhook error",
28
+ "INVALID_CUSTOMER_TYPE" => "Customer type must be either user or organization",
29
+ "INVALID_REQUEST_BODY" => "Invalid request body"
30
+ }.freeze
31
+ end
32
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ module Hooks
6
+ module_function
7
+
8
+ def handle_event(ctx, event)
9
+ event = BetterAuth::Plugins.normalize_hash(event)
10
+ type = event[:type].to_s
11
+ case type
12
+ when "checkout.session.completed"
13
+ on_checkout_completed(ctx, event)
14
+ when "customer.subscription.created"
15
+ on_subscription_created(ctx, event)
16
+ when "customer.subscription.updated"
17
+ on_subscription_updated(ctx, event)
18
+ when "customer.subscription.deleted"
19
+ on_subscription_deleted(ctx, event)
20
+ end
21
+ config = stripe_config(ctx)
22
+ config[:on_event]&.call(event)
23
+ end
24
+
25
+ def on_checkout_completed(ctx, event)
26
+ config = stripe_config(ctx)
27
+ object = BetterAuth::Plugins.normalize_hash(event.dig(:data, :object) || {})
28
+ return if object[:mode] == "setup" || !config.dig(:subscription, :enabled)
29
+
30
+ stripe_subscription = BetterAuth::Stripe::Utils.client(config).subscriptions.retrieve(object[:subscription])
31
+ resolved = BetterAuth::Stripe::Utils.resolve_plan_item(config, stripe_subscription)
32
+ return unless resolved
33
+
34
+ item = resolved.fetch(:item)
35
+ plan = resolved.fetch(:plan)
36
+ metadata = BetterAuth::Plugins.normalize_hash(object[:metadata] || {})
37
+ reference_id = object[:client_reference_id] || metadata[:reference_id]
38
+ subscription_id = metadata[:subscription_id]
39
+ return unless plan && reference_id && subscription_id
40
+
41
+ update = BetterAuth::Stripe::Utils.subscription_state(stripe_subscription, include_status: true).merge(
42
+ plan: plan[:name].to_s.downcase,
43
+ stripeSubscriptionId: object[:subscription],
44
+ seats: BetterAuth::Stripe::Utils.resolve_quantity(stripe_subscription, item, plan),
45
+ trialStart: BetterAuth::Stripe::Utils.time(BetterAuth::Stripe::Utils.fetch(stripe_subscription, "trial_start")),
46
+ trialEnd: BetterAuth::Stripe::Utils.time(BetterAuth::Stripe::Utils.fetch(stripe_subscription, "trial_end"))
47
+ ).compact
48
+ db_subscription = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription_id}], update: update)
49
+ plan.dig(:free_trial, :on_trial_start)&.call(db_subscription) if db_subscription && update[:trialStart]
50
+ callback = config.dig(:subscription, :on_subscription_complete)
51
+ callback&.call({event: event, subscription: db_subscription, stripeSubscription: stripe_subscription, stripe_subscription: stripe_subscription, plan: plan}, ctx)
52
+ end
53
+
54
+ def on_subscription_created(ctx, event)
55
+ config = stripe_config(ctx)
56
+ object = BetterAuth::Plugins.normalize_hash(event.dig(:data, :object) || {})
57
+ customer_id = object[:customer].to_s
58
+ return if customer_id.empty?
59
+
60
+ metadata = BetterAuth::Plugins.normalize_hash(object[:metadata] || {})
61
+ existing = if metadata[:subscription_id]
62
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: metadata[:subscription_id]}])
63
+ else
64
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
65
+ end
66
+ return if existing
67
+
68
+ reference = BetterAuth::Stripe::Middleware.reference_by_customer(ctx, config, customer_id) || ((metadata[:reference_id] && metadata[:plan]) ? {reference_id: metadata[:reference_id], customer_type: metadata[:customer_type] || "user"} : nil)
69
+ return unless reference
70
+
71
+ resolved = BetterAuth::Stripe::Utils.resolve_plan_item(config, object)
72
+ return unless resolved
73
+
74
+ item = resolved.fetch(:item)
75
+ plan = resolved[:plan] || (metadata[:plan] && BetterAuth::Stripe::Utils.plan_by_name(config, metadata[:plan]))
76
+ return unless plan
77
+
78
+ created = ctx.context.adapter.create(
79
+ model: "subscription",
80
+ data: BetterAuth::Stripe::Utils.subscription_state(object, include_status: true).merge(
81
+ referenceId: reference.fetch(:reference_id),
82
+ stripeCustomerId: customer_id,
83
+ stripeSubscriptionId: object[:id],
84
+ plan: plan[:name].to_s.downcase,
85
+ seats: BetterAuth::Stripe::Utils.resolve_quantity(object, item, plan),
86
+ limits: plan[:limits]
87
+ ).compact
88
+ )
89
+ config.dig(:subscription, :on_subscription_created)&.call({event: event, subscription: created, stripeSubscription: object, stripe_subscription: object, plan: plan})
90
+ end
91
+
92
+ def on_subscription_updated(ctx, event)
93
+ config = stripe_config(ctx)
94
+ object = BetterAuth::Plugins.normalize_hash(event.dig(:data, :object) || {})
95
+ resolved = BetterAuth::Stripe::Utils.resolve_plan_item(config, object)
96
+ return unless resolved
97
+
98
+ item = resolved.fetch(:item)
99
+ metadata = BetterAuth::Plugins.normalize_hash(object[:metadata] || {})
100
+ subscription = if metadata[:subscription_id]
101
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: metadata[:subscription_id]}])
102
+ else
103
+ ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
104
+ end
105
+ unless subscription
106
+ candidates = ctx.context.adapter.find_many(model: "subscription", where: [{field: "stripeCustomerId", value: object[:customer]}])
107
+ subscription = if candidates.length > 1
108
+ candidates.find { |entry| BetterAuth::Stripe::Utils.active_or_trialing?(entry) }
109
+ else
110
+ candidates.first
111
+ end
112
+ end
113
+ return unless subscription
114
+
115
+ plan = resolved[:plan]
116
+ was_pending = BetterAuth::Stripe::Utils.pending_cancel?(subscription)
117
+ update = BetterAuth::Stripe::Utils.subscription_state(object, include_status: true, compact: false).merge(
118
+ stripeSubscriptionId: object[:id],
119
+ seats: BetterAuth::Stripe::Utils.resolve_quantity(object, item, plan)
120
+ )
121
+ update[:plan] = plan[:name].to_s.downcase if plan
122
+ update[:limits] = plan[:limits] if plan&.key?(:limits)
123
+ updated = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: update)
124
+ if object[:status] == "active" && BetterAuth::Stripe::Utils.stripe_pending_cancel?(object) && !was_pending
125
+ 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]})
126
+ end
127
+ config.dig(:subscription, :on_subscription_update)&.call({event: event, subscription: updated || subscription})
128
+ if plan && subscription["status"] == "trialing" && object[:status] == "active"
129
+ plan.dig(:free_trial, :on_trial_end)&.call({subscription: subscription}, ctx)
130
+ end
131
+ if plan && subscription["status"] == "trialing" && object[:status] == "incomplete_expired"
132
+ plan.dig(:free_trial, :on_trial_expired)&.call(subscription, ctx)
133
+ end
134
+ end
135
+
136
+ def on_subscription_deleted(ctx, event)
137
+ config = stripe_config(ctx)
138
+ object = BetterAuth::Plugins.normalize_hash(event.dig(:data, :object) || {})
139
+ subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: object[:id]}])
140
+ return unless subscription
141
+
142
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: BetterAuth::Stripe::Utils.subscription_state(object, include_status: false, compact: false).merge(status: "canceled", stripeScheduleId: nil))
143
+ config.dig(:subscription, :on_subscription_deleted)&.call({event: event, subscription: subscription, stripeSubscription: object, stripe_subscription: object})
144
+ end
145
+
146
+ def stripe_config(ctx)
147
+ ctx.context.options.plugins.find { |plugin| plugin.id == "stripe" }&.options || {}
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ module Metadata
6
+ UNSAFE_KEYS = %w[__proto__ constructor prototype].freeze
7
+
8
+ module_function
9
+
10
+ def merge(internal, *user_metadata)
11
+ user_metadata.compact
12
+ .reduce({}) do |acc, entry|
13
+ next acc unless entry.respond_to?(:each)
14
+
15
+ acc.merge(entry.each_with_object({}) do |(key, value), result|
16
+ metadata_key = metadata_key(key)
17
+ result[metadata_key] = value unless UNSAFE_KEYS.include?(metadata_key)
18
+ end)
19
+ end
20
+ .merge(internal.transform_keys { |key| metadata_key(key) })
21
+ end
22
+
23
+ def customer_set(internal_fields, *user_metadata)
24
+ merge(internal_fields, *user_metadata)
25
+ end
26
+
27
+ def customer_get(metadata)
28
+ {
29
+ userId: metadata_fetch(metadata, "userId"),
30
+ organizationId: metadata_fetch(metadata, "organizationId"),
31
+ customerType: metadata_fetch(metadata, "customerType")
32
+ }
33
+ end
34
+
35
+ def subscription_set(internal_fields, *user_metadata)
36
+ merge(internal_fields, *user_metadata)
37
+ end
38
+
39
+ def subscription_get(metadata)
40
+ {
41
+ userId: metadata_fetch(metadata, "userId"),
42
+ subscriptionId: metadata_fetch(metadata, "subscriptionId"),
43
+ referenceId: metadata_fetch(metadata, "referenceId")
44
+ }
45
+ end
46
+
47
+ def metadata_key(key)
48
+ case BetterAuth::Plugins.normalize_key(key)
49
+ when :user_id then "userId"
50
+ when :organization_id then "organizationId"
51
+ when :customer_type then "customerType"
52
+ when :subscription_id then "subscriptionId"
53
+ when :reference_id then "referenceId"
54
+ else
55
+ key.to_s
56
+ end
57
+ end
58
+
59
+ def metadata_fetch(metadata, key)
60
+ return nil unless metadata.respond_to?(:[])
61
+
62
+ metadata[key] || metadata[key.to_sym] || metadata[BetterAuth::Plugins.normalize_key(key)] || metadata[BetterAuth::Plugins.normalize_key(key).to_s]
63
+ end
64
+
65
+ def deep_merge(base, override)
66
+ BetterAuth::Plugins.normalize_hash(base).merge(BetterAuth::Plugins.normalize_hash(override)) do |_key, old, new|
67
+ if old.is_a?(Hash) && new.is_a?(Hash)
68
+ deep_merge(old, new)
69
+ else
70
+ new
71
+ end
72
+ end
73
+ end
74
+
75
+ def stringify_keys(value)
76
+ return value unless value.is_a?(Hash)
77
+
78
+ value.each_with_object({}) do |(key, object), result|
79
+ result[key.to_s] = object
80
+ result[key.to_sym] = object
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ module Middleware
6
+ module_function
7
+
8
+ def reference_id!(_ctx, session, customer_type, explicit_reference_id, config)
9
+ return explicit_reference_id || session.fetch(:user).fetch("id") unless customer_type == "organization"
10
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("ORGANIZATION_SUBSCRIPTION_NOT_ENABLED")) unless config.dig(:organization, :enabled)
11
+
12
+ reference_id = explicit_reference_id || session.fetch(:session)["activeOrganizationId"]
13
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("ORGANIZATION_REFERENCE_ID_REQUIRED")) if reference_id.to_s.empty?
14
+
15
+ reference_id
16
+ end
17
+
18
+ def authorize_reference!(ctx, session, reference_id, action, customer_type, subscription_options, explicit: false)
19
+ callback = subscription_options[:authorize_reference]
20
+ if customer_type == "organization"
21
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("AUTHORIZE_REFERENCE_REQUIRED")) unless callback
22
+ elsif !explicit || reference_id == session.fetch(:user).fetch("id")
23
+ return
24
+ elsif !callback
25
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("REFERENCE_ID_NOT_ALLOWED"))
26
+ end
27
+
28
+ allowed = callback.call({user: session.fetch(:user), session: session.fetch(:session), referenceId: reference_id, reference_id: reference_id, action: action}, ctx)
29
+ raise APIError.new("UNAUTHORIZED", message: BetterAuth::Stripe::ERROR_CODES.fetch("UNAUTHORIZED")) unless allowed
30
+ end
31
+
32
+ def customer_type!(source)
33
+ body = BetterAuth::Plugins.normalize_hash(source || {})
34
+ customer_type = (body[:customer_type] || "user").to_s
35
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("INVALID_CUSTOMER_TYPE")) unless BetterAuth::Stripe::Types::CUSTOMER_TYPES.include?(customer_type)
36
+
37
+ customer_type
38
+ end
39
+
40
+ def reference_by_customer(ctx, config, customer_id)
41
+ if config.dig(:organization, :enabled)
42
+ org = ctx.context.adapter.find_one(model: "organization", where: [{field: "stripeCustomerId", value: customer_id}])
43
+ return {customer_type: "organization", reference_id: org.fetch("id")} if org
44
+ end
45
+ user = ctx.context.adapter.find_one(model: "user", where: [{field: "stripeCustomerId", value: customer_id}])
46
+ return {customer_type: "user", reference_id: user.fetch("id")} if user
47
+
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ module OrganizationHooks
6
+ module_function
7
+
8
+ def hooks(config)
9
+ return {} unless config.dig(:organization, :enabled)
10
+
11
+ {
12
+ after_update_organization: lambda do |data, _ctx|
13
+ organization = data[:organization] || data["organization"]
14
+ next unless organization && organization["stripeCustomerId"]
15
+
16
+ customer = BetterAuth::Stripe::Utils.client(config).customers.retrieve(organization["stripeCustomerId"])
17
+ next if BetterAuth::Stripe::Utils.fetch(customer, "deleted")
18
+ next if BetterAuth::Stripe::Utils.fetch(customer, "name") == organization["name"]
19
+
20
+ BetterAuth::Stripe::Utils.client(config).customers.update(organization["stripeCustomerId"], name: organization["name"])
21
+ rescue
22
+ nil
23
+ end,
24
+ before_delete_organization: lambda do |data, _ctx|
25
+ organization = data[:organization] || data["organization"]
26
+ next unless organization && organization["stripeCustomerId"]
27
+
28
+ subscriptions = BetterAuth::Stripe::Utils.client(config).subscriptions.list(customer: organization["stripeCustomerId"], status: "all", limit: 100)
29
+ active = Array(BetterAuth::Stripe::Utils.fetch(subscriptions, "data")).any? do |subscription|
30
+ !%w[canceled incomplete incomplete_expired].include?(BetterAuth::Stripe::Utils.fetch(subscription, "status").to_s)
31
+ end
32
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION")) if active
33
+ end,
34
+ after_add_member: ->(data, ctx) { sync_seats(config, data, ctx) },
35
+ after_remove_member: ->(data, ctx) { sync_seats(config, data, ctx) },
36
+ after_accept_invitation: ->(data, ctx) { sync_seats(config, data, ctx) }
37
+ }
38
+ end
39
+
40
+ def sync_seats(config, data, ctx)
41
+ organization = data[:organization] || data["organization"]
42
+ return unless config.dig(:subscription, :enabled) && organization && organization["stripeCustomerId"]
43
+
44
+ member_count = ctx.context.adapter.count(model: "member", where: [{field: "organizationId", value: organization.fetch("id")}])
45
+ seat_plans = BetterAuth::Stripe::Utils.plans(config).select { |plan| plan[:seat_price_id] }
46
+ return if seat_plans.empty?
47
+
48
+ subscription = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: organization.fetch("id")}]).find { |entry| BetterAuth::Stripe::Utils.active_or_trialing?(entry) }
49
+ return unless subscription && subscription["stripeSubscriptionId"]
50
+
51
+ plan = seat_plans.find { |entry| entry[:name].to_s.downcase == subscription["plan"].to_s.downcase }
52
+ return unless plan
53
+
54
+ stripe_subscription = BetterAuth::Stripe::Utils.client(config).subscriptions.retrieve(subscription["stripeSubscriptionId"])
55
+ return unless BetterAuth::Stripe::Utils.active_or_trialing?(stripe_subscription)
56
+
57
+ items = Array(BetterAuth::Stripe::Utils.fetch(BetterAuth::Stripe::Utils.fetch(stripe_subscription, "items") || {}, "data"))
58
+ seat_item = items.find { |item| BetterAuth::Stripe::Utils.fetch(BetterAuth::Stripe::Utils.fetch(item, "price") || {}, "id") == plan[:seat_price_id] }
59
+ return if seat_item && BetterAuth::Stripe::Utils.fetch(seat_item, "quantity").to_i == member_count.to_i
60
+
61
+ update_items = if seat_item
62
+ [{id: BetterAuth::Stripe::Utils.fetch(seat_item, "id"), quantity: member_count}]
63
+ else
64
+ [{price: plan[:seat_price_id], quantity: member_count}]
65
+ end
66
+ BetterAuth::Stripe::Utils.client(config).subscriptions.update(subscription["stripeSubscriptionId"], items: update_items, proration_behavior: plan[:proration_behavior] || "create_prorations")
67
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {seats: member_count})
68
+ rescue
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module BetterAuth
6
+ module Stripe
7
+ module PluginFactory
8
+ module_function
9
+
10
+ def build(options = {})
11
+ config = BetterAuth::Plugins.normalize_hash(options)
12
+ BetterAuth::Plugin.new(
13
+ id: "stripe",
14
+ version: BetterAuth::Stripe::VERSION,
15
+ init: ->(ctx) { {context: {schema: BetterAuth::Schema.auth_tables(ctx.options)}} },
16
+ schema: BetterAuth::Stripe::Schema.schema(config),
17
+ endpoints: BetterAuth::Stripe::Routes.endpoints(config),
18
+ error_codes: BetterAuth::Stripe::ERROR_CODES,
19
+ options: config.merge(database_hooks: database_hooks(config), organization_hooks: BetterAuth::Stripe::OrganizationHooks.hooks(config))
20
+ )
21
+ end
22
+
23
+ def database_hooks(config)
24
+ return {} unless config[:create_customer_on_sign_up]
25
+
26
+ {
27
+ user: {
28
+ create: {
29
+ before: lambda do |data, hook_ctx|
30
+ next unless data["email"] && !data["stripeCustomerId"]
31
+
32
+ data["id"] ||= SecureRandom.hex(16)
33
+ customer = BetterAuth::Plugins.stripe_find_or_create_user_customer(config, data, nil, hook_ctx)
34
+ {data: {id: data["id"], stripeCustomerId: BetterAuth::Stripe::Utils.id(customer)}}
35
+ rescue
36
+ nil
37
+ end
38
+ },
39
+ update: {
40
+ after: lambda do |user, _ctx|
41
+ next unless user && user["stripeCustomerId"]
42
+
43
+ customer = BetterAuth::Stripe::Utils.client(config).customers.retrieve(user["stripeCustomerId"])
44
+ next if BetterAuth::Stripe::Utils.fetch(customer, "deleted")
45
+ next if BetterAuth::Stripe::Utils.fetch(customer, "email") == user["email"]
46
+
47
+ BetterAuth::Stripe::Utils.client(config).customers.update(user["stripeCustomerId"], email: user["email"])
48
+ rescue
49
+ nil
50
+ end
51
+ }
52
+ }
53
+ }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ module Routes
6
+ module CancelSubscription
7
+ module_function
8
+
9
+ def endpoint(config)
10
+ BetterAuth::Endpoint.new(path: "/subscription/cancel", method: "POST") do |ctx|
11
+ session = BetterAuth::Routes.current_session(ctx)
12
+ body = BetterAuth::Plugins.normalize_hash(ctx.body)
13
+ customer_type = BetterAuth::Plugins.stripe_customer_type!(body)
14
+ reference_id = BetterAuth::Plugins.stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
15
+ BetterAuth::Plugins.stripe_authorize_reference!(ctx, session, reference_id, "cancel-subscription", customer_type, BetterAuth::Plugins.stripe_subscription_options(config), explicit: body.key?(:reference_id))
16
+ subscription = BetterAuth::Plugins.stripe_find_subscription_for_action(ctx, reference_id, body[:subscription_id], active_only: true)
17
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription && subscription["stripeCustomerId"]
18
+
19
+ active = BetterAuth::Plugins.stripe_active_subscriptions(config, subscription["stripeCustomerId"])
20
+ if active.empty?
21
+ ctx.context.adapter.delete_many(model: "subscription", where: [{field: "referenceId", value: reference_id}])
22
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND"))
23
+ end
24
+ stripe_subscription = active.find { |entry| BetterAuth::Plugins.stripe_fetch(entry, "id") == subscription["stripeSubscriptionId"] }
25
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless stripe_subscription
26
+
27
+ portal = BetterAuth::Plugins.stripe_client(config).billing_portal.sessions.create(
28
+ customer: subscription["stripeCustomerId"],
29
+ return_url: BetterAuth::Plugins.stripe_url(ctx, "#{ctx.context.base_url}/subscription/cancel/callback?callbackURL=#{Rack::Utils.escape(body[:return_url] || "/")}&subscriptionId=#{Rack::Utils.escape(subscription.fetch("id"))}"),
30
+ flow_data: {type: "subscription_cancel", subscription_cancel: {subscription: BetterAuth::Plugins.stripe_fetch(stripe_subscription, "id")}}
31
+ )
32
+ ctx.json(BetterAuth::Plugins.stripe_stringify_keys(portal).merge(redirect: BetterAuth::Plugins.stripe_redirect?(body)))
33
+ rescue BetterAuth::APIError
34
+ raise
35
+ rescue => error
36
+ if error.message.include?("already set to be canceled") && subscription && !BetterAuth::Plugins.stripe_pending_cancel?(subscription)
37
+ stripe_sub = BetterAuth::Plugins.stripe_client(config).subscriptions.retrieve(subscription["stripeSubscriptionId"])
38
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: BetterAuth::Plugins.stripe_subscription_state(stripe_sub, include_status: false))
39
+ end
40
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: error.message)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ module Routes
6
+ module CancelSubscriptionCallback
7
+ module_function
8
+
9
+ def endpoint(config)
10
+ BetterAuth::Endpoint.new(path: "/subscription/cancel/callback", method: "GET") do |ctx|
11
+ query = BetterAuth::Plugins.normalize_hash(ctx.query)
12
+ callback = query[:callback_url] || "/"
13
+ unless query[:subscription_id]
14
+ raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback))
15
+ end
16
+ session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
17
+ raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback)) unless session
18
+
19
+ subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: query[:subscription_id]}])
20
+ if subscription && !BetterAuth::Plugins.stripe_pending_cancel?(subscription) && subscription["stripeCustomerId"]
21
+ current = BetterAuth::Plugins.stripe_active_subscriptions(config || {}, subscription["stripeCustomerId"]).find { |entry| BetterAuth::Plugins.stripe_fetch(entry, "id") == subscription["stripeSubscriptionId"] }
22
+ if current && BetterAuth::Plugins.stripe_stripe_pending_cancel?(current)
23
+ ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: BetterAuth::Plugins.stripe_subscription_state(current, include_status: true))
24
+ BetterAuth::Plugins.stripe_subscription_options(config || {})[:on_subscription_cancel]&.call({subscription: subscription, stripeSubscription: current, stripe_subscription: current, cancellationDetails: BetterAuth::Plugins.stripe_fetch(current, "cancellation_details"), cancellation_details: BetterAuth::Plugins.stripe_fetch(current, "cancellation_details"), event: nil})
25
+ end
26
+ end
27
+ raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback))
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Stripe
5
+ module Routes
6
+ module CreateBillingPortal
7
+ module_function
8
+
9
+ def endpoint(config)
10
+ BetterAuth::Endpoint.new(path: "/subscription/billing-portal", method: "POST") do |ctx|
11
+ session = BetterAuth::Routes.current_session(ctx)
12
+ body = BetterAuth::Plugins.normalize_hash(ctx.body)
13
+ customer_type = BetterAuth::Plugins.stripe_customer_type!(body)
14
+ reference_id = BetterAuth::Plugins.stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
15
+ BetterAuth::Plugins.stripe_authorize_reference!(ctx, session, reference_id, "billing-portal", customer_type, BetterAuth::Plugins.stripe_subscription_options(config), explicit: body.key?(:reference_id))
16
+ customer_id = if customer_type == "organization"
17
+ org = ctx.context.adapter.find_one(model: "organization", where: [{field: "id", value: reference_id}])
18
+ org&.fetch("stripeCustomerId", nil) || BetterAuth::Plugins.stripe_active_subscription(ctx, reference_id)&.fetch("stripeCustomerId", nil)
19
+ else
20
+ session.fetch(:user)["stripeCustomerId"] || BetterAuth::Plugins.stripe_active_subscription(ctx, reference_id)&.fetch("stripeCustomerId", nil)
21
+ end
22
+ raise BetterAuth::APIError.new("NOT_FOUND", message: BetterAuth::Stripe::ERROR_CODES.fetch("CUSTOMER_NOT_FOUND")) unless customer_id
23
+
24
+ portal = BetterAuth::Plugins.stripe_client(config).billing_portal.sessions.create(customer: customer_id, return_url: BetterAuth::Plugins.stripe_url(ctx, body[:return_url] || "/"), locale: body[:locale])
25
+ ctx.json(BetterAuth::Plugins.stripe_stringify_keys(portal).merge(redirect: BetterAuth::Plugins.stripe_redirect?(body)))
26
+ rescue BetterAuth::APIError
27
+ raise
28
+ rescue
29
+ raise BetterAuth::APIError.new("INTERNAL_SERVER_ERROR", message: BetterAuth::Stripe::ERROR_CODES.fetch("UNABLE_TO_CREATE_BILLING_PORTAL"))
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end