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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/lib/better_auth/plugins/stripe.rb +62 -874
- data/lib/better_auth/stripe/client_adapter.rb +71 -0
- data/lib/better_auth/stripe/error_codes.rb +32 -0
- data/lib/better_auth/stripe/hooks.rb +151 -0
- data/lib/better_auth/stripe/metadata.rb +85 -0
- data/lib/better_auth/stripe/middleware.rb +52 -0
- data/lib/better_auth/stripe/organization_hooks.rb +73 -0
- data/lib/better_auth/stripe/plugin_factory.rb +57 -0
- data/lib/better_auth/stripe/routes/cancel_subscription.rb +46 -0
- data/lib/better_auth/stripe/routes/cancel_subscription_callback.rb +33 -0
- data/lib/better_auth/stripe/routes/create_billing_portal.rb +35 -0
- data/lib/better_auth/stripe/routes/index.rb +24 -0
- data/lib/better_auth/stripe/routes/list_active_subscriptions.rb +32 -0
- data/lib/better_auth/stripe/routes/restore_subscription.rb +49 -0
- data/lib/better_auth/stripe/routes/stripe_webhook.rb +42 -0
- data/lib/better_auth/stripe/routes/subscription_success.rb +64 -0
- data/lib/better_auth/stripe/routes/upgrade_subscription.rb +149 -0
- data/lib/better_auth/stripe/schema.rb +93 -0
- data/lib/better_auth/stripe/types.rb +18 -0
- data/lib/better_auth/stripe/utils.rb +190 -0
- data/lib/better_auth/stripe/version.rb +1 -1
- data/lib/better_auth/stripe.rb +19 -5
- metadata +20 -1
|
@@ -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
|