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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Routes
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def endpoints(config)
|
|
9
|
+
endpoints = {stripe_webhook: BetterAuth::Stripe::Routes::StripeWebhook.endpoint(config)}
|
|
10
|
+
return endpoints unless config.dig(:subscription, :enabled)
|
|
11
|
+
|
|
12
|
+
endpoints.merge(
|
|
13
|
+
upgrade_subscription: BetterAuth::Stripe::Routes::UpgradeSubscription.endpoint(config),
|
|
14
|
+
cancel_subscription_callback: BetterAuth::Stripe::Routes::CancelSubscriptionCallback.endpoint(config),
|
|
15
|
+
cancel_subscription: BetterAuth::Stripe::Routes::CancelSubscription.endpoint(config),
|
|
16
|
+
restore_subscription: BetterAuth::Stripe::Routes::RestoreSubscription.endpoint(config),
|
|
17
|
+
list_active_subscriptions: BetterAuth::Stripe::Routes::ListActiveSubscriptions.endpoint(config),
|
|
18
|
+
subscription_success: BetterAuth::Stripe::Routes::SubscriptionSuccess.endpoint(config),
|
|
19
|
+
create_billing_portal: BetterAuth::Stripe::Routes::CreateBillingPortal.endpoint(config)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Routes
|
|
6
|
+
module ListActiveSubscriptions
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def endpoint(config)
|
|
10
|
+
BetterAuth::Endpoint.new(path: "/subscription/list", method: "GET") do |ctx|
|
|
11
|
+
session = BetterAuth::Routes.current_session(ctx)
|
|
12
|
+
query = BetterAuth::Plugins.normalize_hash(ctx.query)
|
|
13
|
+
customer_type = BetterAuth::Plugins.stripe_customer_type!(query)
|
|
14
|
+
reference_id = BetterAuth::Plugins.stripe_reference_id!(ctx, session, customer_type, query[:reference_id], config)
|
|
15
|
+
BetterAuth::Plugins.stripe_authorize_reference!(ctx, session, reference_id, "list-subscription", customer_type, BetterAuth::Plugins.stripe_subscription_options(config), explicit: query.key?(:reference_id))
|
|
16
|
+
plans = BetterAuth::Plugins.stripe_plans(config)
|
|
17
|
+
subscriptions = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).select { |entry| BetterAuth::Plugins.stripe_active_or_trialing?(entry) }
|
|
18
|
+
ctx.json(subscriptions.map do |entry|
|
|
19
|
+
plan = plans.find { |item| item[:name].to_s.downcase == entry["plan"].to_s.downcase }
|
|
20
|
+
price_id = if entry["billingInterval"] == "year"
|
|
21
|
+
plan&.fetch(:annual_discount_price_id, nil) || plan&.fetch(:price_id, nil)
|
|
22
|
+
else
|
|
23
|
+
plan&.fetch(:price_id, nil)
|
|
24
|
+
end
|
|
25
|
+
entry.merge("limits" => plan&.fetch(:limits, nil), "priceId" => price_id)
|
|
26
|
+
end)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Routes
|
|
6
|
+
module RestoreSubscription
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def endpoint(config)
|
|
10
|
+
BetterAuth::Endpoint.new(path: "/subscription/restore", 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, "restore-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: false)
|
|
17
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription && subscription["stripeCustomerId"]
|
|
18
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_ACTIVE")) unless BetterAuth::Plugins.stripe_active_or_trialing?(subscription)
|
|
19
|
+
|
|
20
|
+
if subscription["stripeScheduleId"]
|
|
21
|
+
schedule = BetterAuth::Plugins.stripe_client(config).subscription_schedules.retrieve(subscription["stripeScheduleId"])
|
|
22
|
+
if BetterAuth::Plugins.stripe_fetch(schedule, "status") == "active"
|
|
23
|
+
schedule = BetterAuth::Plugins.stripe_client(config).subscription_schedules.release(subscription["stripeScheduleId"])
|
|
24
|
+
end
|
|
25
|
+
ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {stripeScheduleId: nil})
|
|
26
|
+
next ctx.json(BetterAuth::Plugins.stripe_stringify_keys(schedule))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION")) unless BetterAuth::Plugins.stripe_pending_cancel?(subscription)
|
|
30
|
+
|
|
31
|
+
active = BetterAuth::Plugins.stripe_active_subscriptions(config, subscription["stripeCustomerId"]).first
|
|
32
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless active
|
|
33
|
+
|
|
34
|
+
update_params = if BetterAuth::Plugins.stripe_fetch(active, "cancel_at")
|
|
35
|
+
{cancel_at: ""}
|
|
36
|
+
elsif BetterAuth::Plugins.stripe_fetch(active, "cancel_at_period_end")
|
|
37
|
+
{cancel_at_period_end: false}
|
|
38
|
+
else
|
|
39
|
+
{}
|
|
40
|
+
end
|
|
41
|
+
restored = BetterAuth::Plugins.stripe_client(config).subscriptions.update(BetterAuth::Plugins.stripe_fetch(active, "id"), update_params)
|
|
42
|
+
ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: {cancelAtPeriodEnd: false, cancelAt: nil, canceledAt: nil})
|
|
43
|
+
ctx.json(BetterAuth::Plugins.stripe_stringify_keys(restored))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Routes
|
|
6
|
+
module StripeWebhook
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def endpoint(config)
|
|
10
|
+
BetterAuth::Endpoint.new(path: "/stripe/webhook", method: "POST") do |ctx|
|
|
11
|
+
signature = ctx.headers["stripe-signature"]
|
|
12
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("STRIPE_SIGNATURE_NOT_FOUND")) if signature.to_s.empty?
|
|
13
|
+
|
|
14
|
+
raise BetterAuth::APIError.new("INTERNAL_SERVER_ERROR", message: BetterAuth::Stripe::ERROR_CODES.fetch("STRIPE_WEBHOOK_SECRET_NOT_FOUND")) if config[:stripe_webhook_secret].to_s.empty?
|
|
15
|
+
|
|
16
|
+
event = begin
|
|
17
|
+
if BetterAuth::Plugins.stripe_client(config).respond_to?(:webhooks)
|
|
18
|
+
webhooks = BetterAuth::Plugins.stripe_client(config).webhooks
|
|
19
|
+
if webhooks.respond_to?(:construct_event_async)
|
|
20
|
+
webhooks.construct_event_async(ctx.body, signature, config[:stripe_webhook_secret])
|
|
21
|
+
else
|
|
22
|
+
webhooks.construct_event(ctx.body, signature, config[:stripe_webhook_secret])
|
|
23
|
+
end
|
|
24
|
+
else
|
|
25
|
+
ctx.body
|
|
26
|
+
end
|
|
27
|
+
rescue
|
|
28
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT"))
|
|
29
|
+
end
|
|
30
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("FAILED_TO_CONSTRUCT_STRIPE_EVENT")) unless event
|
|
31
|
+
begin
|
|
32
|
+
BetterAuth::Plugins.stripe_handle_event(ctx, event)
|
|
33
|
+
rescue
|
|
34
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("STRIPE_WEBHOOK_ERROR"))
|
|
35
|
+
end
|
|
36
|
+
ctx.json({success: true})
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Routes
|
|
6
|
+
module SubscriptionSuccess
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def endpoint(config)
|
|
10
|
+
BetterAuth::Endpoint.new(path: "/subscription/success", method: "GET") do |ctx|
|
|
11
|
+
query = BetterAuth::Plugins.normalize_hash(ctx.query)
|
|
12
|
+
callback = query[:callback_url] || "/"
|
|
13
|
+
checkout_session_id = query[:checkout_session_id]
|
|
14
|
+
subscription_id = query[:subscription_id]
|
|
15
|
+
if checkout_session_id
|
|
16
|
+
callback = callback.to_s.gsub("{CHECKOUT_SESSION_ID}", checkout_session_id.to_s)
|
|
17
|
+
checkout_session = begin
|
|
18
|
+
BetterAuth::Plugins.stripe_client(config || {}).checkout.sessions.retrieve(checkout_session_id)
|
|
19
|
+
rescue
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback)) unless checkout_session
|
|
23
|
+
|
|
24
|
+
metadata = BetterAuth::Plugins.normalize_hash(BetterAuth::Plugins.stripe_fetch(checkout_session || {}, "metadata") || {})
|
|
25
|
+
subscription_id = metadata[:subscription_id]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
unless subscription_id
|
|
29
|
+
raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback))
|
|
30
|
+
end
|
|
31
|
+
session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
|
|
32
|
+
raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback)) unless session
|
|
33
|
+
|
|
34
|
+
subscription = ctx.context.adapter.find_one(model: "subscription", where: [{field: "id", value: subscription_id}])
|
|
35
|
+
raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback)) unless subscription
|
|
36
|
+
raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback)) if BetterAuth::Plugins.stripe_active_or_trialing?(subscription)
|
|
37
|
+
|
|
38
|
+
customer_id = subscription["stripeCustomerId"] || session.fetch(:user)["stripeCustomerId"]
|
|
39
|
+
raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback)) unless customer_id
|
|
40
|
+
|
|
41
|
+
stripe_subscription = BetterAuth::Plugins.stripe_active_subscriptions(config || {}, customer_id).first
|
|
42
|
+
if stripe_subscription
|
|
43
|
+
resolved = BetterAuth::Plugins.stripe_resolve_plan_item(config || {}, stripe_subscription)
|
|
44
|
+
item = resolved&.fetch(:item, nil)
|
|
45
|
+
plan = resolved&.fetch(:plan, nil)
|
|
46
|
+
if item && plan
|
|
47
|
+
ctx.context.adapter.update(
|
|
48
|
+
model: "subscription",
|
|
49
|
+
where: [{field: "id", value: subscription.fetch("id")}],
|
|
50
|
+
update: BetterAuth::Plugins.stripe_subscription_state(stripe_subscription, include_status: true, compact: false).merge(
|
|
51
|
+
plan: plan[:name].to_s.downcase,
|
|
52
|
+
seats: BetterAuth::Plugins.stripe_resolve_quantity(stripe_subscription, item, plan),
|
|
53
|
+
stripeSubscriptionId: BetterAuth::Plugins.stripe_fetch(stripe_subscription, "id")
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
raise ctx.redirect(BetterAuth::Plugins.stripe_url(ctx, callback))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Routes
|
|
6
|
+
module UpgradeSubscription
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def endpoint(config)
|
|
10
|
+
BetterAuth::Endpoint.new(path: "/subscription/upgrade", method: "POST") do |ctx|
|
|
11
|
+
session = BetterAuth::Routes.current_session(ctx)
|
|
12
|
+
body = BetterAuth::Plugins.normalize_hash(ctx.body)
|
|
13
|
+
subscription_options = BetterAuth::Plugins.stripe_subscription_options(config)
|
|
14
|
+
customer_type = BetterAuth::Plugins.stripe_customer_type!(body)
|
|
15
|
+
reference_id = BetterAuth::Plugins.stripe_reference_id!(ctx, session, customer_type, body[:reference_id], config)
|
|
16
|
+
BetterAuth::Plugins.stripe_authorize_reference!(ctx, session, reference_id, "upgrade-subscription", customer_type, subscription_options, explicit: body.key?(:reference_id))
|
|
17
|
+
|
|
18
|
+
user = session.fetch(:user)
|
|
19
|
+
if subscription_options[:require_email_verification] && !user["emailVerified"]
|
|
20
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
plan = BetterAuth::Plugins.stripe_plan_by_name(config, body[:plan])
|
|
24
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_PLAN_NOT_FOUND")) unless plan
|
|
25
|
+
|
|
26
|
+
subscription_to_update = nil
|
|
27
|
+
if body[:subscription_id]
|
|
28
|
+
subscription_to_update = ctx.context.adapter.find_one(model: "subscription", where: [{field: "stripeSubscriptionId", value: body[:subscription_id]}])
|
|
29
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("SUBSCRIPTION_NOT_FOUND")) unless subscription_to_update && subscription_to_update["referenceId"] == reference_id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
subscriptions = subscription_to_update ? [subscription_to_update] : ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}])
|
|
33
|
+
reference_customer_id = subscriptions.find { |entry| entry["stripeCustomerId"] }&.fetch("stripeCustomerId", nil)
|
|
34
|
+
customer_id = if customer_type == "organization"
|
|
35
|
+
subscription_to_update&.fetch("stripeCustomerId", nil) || reference_customer_id || BetterAuth::Plugins.stripe_organization_customer(config, ctx, reference_id, body[:metadata])
|
|
36
|
+
else
|
|
37
|
+
subscription_to_update&.fetch("stripeCustomerId", nil) || reference_customer_id || user["stripeCustomerId"] || BetterAuth::Plugins.stripe_create_customer(config, ctx, user, body[:metadata])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
active_or_trialing = subscriptions.find { |entry| BetterAuth::Plugins.stripe_active_or_trialing?(entry) }
|
|
41
|
+
active_stripe_subscriptions = BetterAuth::Plugins.stripe_active_subscriptions(config, customer_id)
|
|
42
|
+
active_stripe = active_stripe_subscriptions.find do |entry|
|
|
43
|
+
if subscription_to_update&.fetch("stripeSubscriptionId", nil) || body[:subscription_id]
|
|
44
|
+
BetterAuth::Plugins.stripe_fetch(entry, "id") == subscription_to_update&.fetch("stripeSubscriptionId", nil) || BetterAuth::Plugins.stripe_fetch(entry, "id") == body[:subscription_id]
|
|
45
|
+
elsif active_or_trialing && active_or_trialing["stripeSubscriptionId"]
|
|
46
|
+
BetterAuth::Plugins.stripe_fetch(entry, "id") == active_or_trialing["stripeSubscriptionId"]
|
|
47
|
+
else
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
price_id = BetterAuth::Plugins.stripe_price_id(config, plan, body[:annual])
|
|
53
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: "Price ID not found for the selected plan") if price_id.to_s.empty?
|
|
54
|
+
auto_managed_seats = !!(plan[:seat_price_id] && customer_type == "organization")
|
|
55
|
+
member_count = auto_managed_seats ? ctx.context.adapter.count(model: "member", where: [{field: "organizationId", value: reference_id}]) : 0
|
|
56
|
+
requested_seats = auto_managed_seats ? member_count : (body[:seats] || 1)
|
|
57
|
+
seat_only_plan = auto_managed_seats && plan[:seat_price_id] == price_id
|
|
58
|
+
|
|
59
|
+
active_resolved = active_stripe && BetterAuth::Plugins.stripe_resolve_plan_item(config, active_stripe)
|
|
60
|
+
active_stripe_item = active_resolved&.fetch(:item, nil) || BetterAuth::Plugins.stripe_subscription_item(active_stripe || {})
|
|
61
|
+
stripe_price_id_value = BetterAuth::Plugins.stripe_fetch(BetterAuth::Plugins.stripe_fetch(active_stripe_item || {}, "price") || {}, "id")
|
|
62
|
+
same_plan = active_or_trialing && active_or_trialing["plan"].to_s.downcase == body[:plan].to_s.downcase
|
|
63
|
+
same_seats = auto_managed_seats || (active_or_trialing && active_or_trialing["seats"].to_i == requested_seats.to_i)
|
|
64
|
+
same_price = !active_stripe || stripe_price_id_value == price_id
|
|
65
|
+
valid_period = !active_or_trialing || !active_or_trialing["periodEnd"] || active_or_trialing["periodEnd"] > Time.now
|
|
66
|
+
if active_or_trialing&.fetch("status", nil) == "active" && same_plan && same_seats && same_price && valid_period
|
|
67
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Stripe::ERROR_CODES.fetch("ALREADY_SUBSCRIBED_PLAN"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if active_stripe
|
|
71
|
+
BetterAuth::Plugins.stripe_release_plugin_schedule(ctx, config, customer_id, active_stripe, active_or_trialing || subscription_to_update)
|
|
72
|
+
|
|
73
|
+
if body[:schedule_at_period_end]
|
|
74
|
+
url = BetterAuth::Plugins.stripe_schedule_plan_change(ctx, config, active_stripe, active_or_trialing, plan, price_id, requested_seats, seat_only_plan, body)
|
|
75
|
+
next ctx.json({url: url, redirect: BetterAuth::Plugins.stripe_redirect?(body)})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
old_plan = active_or_trialing && BetterAuth::Plugins.stripe_plan_by_name(config, active_or_trialing["plan"])
|
|
79
|
+
if BetterAuth::Plugins.stripe_direct_subscription_update?(old_plan, plan, auto_managed_seats)
|
|
80
|
+
url = BetterAuth::Plugins.stripe_update_active_subscription_items(ctx, config, active_stripe, active_or_trialing, old_plan, plan, price_id, requested_seats, seat_only_plan, body)
|
|
81
|
+
next ctx.json({url: url, redirect: BetterAuth::Plugins.stripe_redirect?(body)})
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
portal = BetterAuth::Plugins.stripe_client(config).billing_portal.sessions.create(
|
|
85
|
+
customer: customer_id,
|
|
86
|
+
return_url: BetterAuth::Plugins.stripe_url(ctx, body[:return_url] || "/"),
|
|
87
|
+
flow_data: {
|
|
88
|
+
type: "subscription_update_confirm",
|
|
89
|
+
after_completion: {type: "redirect", redirect: {return_url: BetterAuth::Plugins.stripe_url(ctx, body[:return_url] || "/")}},
|
|
90
|
+
subscription_update_confirm: {
|
|
91
|
+
subscription: BetterAuth::Plugins.stripe_fetch(active_stripe, "id"),
|
|
92
|
+
items: [BetterAuth::Plugins.stripe_line_item(config, price_id, requested_seats).merge(id: BetterAuth::Plugins.stripe_fetch(active_stripe_item || {}, "id"))]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
next ctx.json(BetterAuth::Plugins.stripe_stringify_keys(portal).merge(redirect: BetterAuth::Plugins.stripe_redirect?(body)))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
incomplete = subscriptions.find { |entry| entry["status"] == "incomplete" }
|
|
100
|
+
subscription = active_or_trialing || incomplete
|
|
101
|
+
if subscription
|
|
102
|
+
update = {plan: plan[:name].to_s.downcase, seats: requested_seats}
|
|
103
|
+
subscription = ctx.context.adapter.update(model: "subscription", where: [{field: "id", value: subscription.fetch("id")}], update: update) || subscription.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
|
|
104
|
+
else
|
|
105
|
+
subscription = ctx.context.adapter.create(
|
|
106
|
+
model: "subscription",
|
|
107
|
+
data: {plan: plan[:name].to_s.downcase, referenceId: reference_id, stripeCustomerId: customer_id, status: "incomplete", seats: requested_seats, limits: plan[:limits]}
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
has_ever_trialed = ctx.context.adapter.find_many(model: "subscription", where: [{field: "referenceId", value: reference_id}]).any? do |entry|
|
|
112
|
+
entry["trialStart"] || entry["trialEnd"] || entry["status"] == "trialing"
|
|
113
|
+
end
|
|
114
|
+
free_trial = (!has_ever_trialed && plan[:free_trial]) ? {trial_period_days: plan.dig(:free_trial, :days)} : {}
|
|
115
|
+
checkout_customization = subscription_options[:get_checkout_session_params]&.call(
|
|
116
|
+
{user: user, session: session.fetch(:session), plan: plan, subscription: subscription},
|
|
117
|
+
ctx.request,
|
|
118
|
+
ctx
|
|
119
|
+
) || {}
|
|
120
|
+
custom_params = BetterAuth::Plugins.stripe_fetch(checkout_customization, "params") || {}
|
|
121
|
+
custom_options = BetterAuth::Plugins.normalize_hash(BetterAuth::Plugins.stripe_fetch(checkout_customization, "options") || {})
|
|
122
|
+
custom_subscription_data = BetterAuth::Plugins.stripe_fetch(custom_params, "subscription_data") || BetterAuth::Plugins.stripe_fetch(custom_params, "subscriptionData") || {}
|
|
123
|
+
internal_metadata = {userId: user.fetch("id"), subscriptionId: subscription.fetch("id"), referenceId: reference_id}
|
|
124
|
+
metadata = BetterAuth::Plugins.stripe_subscription_metadata_set(internal_metadata, body[:metadata], BetterAuth::Plugins.stripe_fetch(custom_params, "metadata"))
|
|
125
|
+
subscription_metadata = BetterAuth::Plugins.stripe_subscription_metadata_set(internal_metadata, body[:metadata], BetterAuth::Plugins.stripe_fetch(custom_subscription_data, "metadata"))
|
|
126
|
+
checkout_params = BetterAuth::Plugins.stripe_deep_merge(
|
|
127
|
+
custom_params,
|
|
128
|
+
customer: customer_id,
|
|
129
|
+
customer_update: (customer_type == "user") ? {name: "auto", address: "auto"} : {address: "auto"},
|
|
130
|
+
locale: body[:locale],
|
|
131
|
+
success_url: BetterAuth::Plugins.stripe_url(ctx, "#{ctx.context.base_url}/subscription/success?callbackURL=#{Rack::Utils.escape(body[:success_url] || "/")}&checkoutSessionId={CHECKOUT_SESSION_ID}"),
|
|
132
|
+
cancel_url: BetterAuth::Plugins.stripe_url(ctx, body[:cancel_url] || "/"),
|
|
133
|
+
line_items: BetterAuth::Plugins.stripe_checkout_line_items(config, plan, price_id, requested_seats, auto_managed_seats, seat_only_plan),
|
|
134
|
+
subscription_data: free_trial.merge(metadata: subscription_metadata),
|
|
135
|
+
mode: "subscription",
|
|
136
|
+
client_reference_id: reference_id,
|
|
137
|
+
metadata: metadata
|
|
138
|
+
)
|
|
139
|
+
checkout_params[:metadata] = metadata
|
|
140
|
+
checkout_params[:subscription_data] ||= {}
|
|
141
|
+
checkout_params[:subscription_data][:metadata] = subscription_metadata
|
|
142
|
+
checkout = BetterAuth::Plugins.stripe_client(config).checkout.sessions.create(checkout_params, custom_options.empty? ? nil : custom_options)
|
|
143
|
+
ctx.json(BetterAuth::Plugins.stripe_stringify_keys(checkout).merge(redirect: BetterAuth::Plugins.stripe_redirect?(body)))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Schema
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def schema(config)
|
|
9
|
+
custom_schema = custom_schema(config)
|
|
10
|
+
config = BetterAuth::Plugins.normalize_hash((config || {}).reject { |key, _| key.to_s == "schema" })
|
|
11
|
+
base_schema = {
|
|
12
|
+
user: {
|
|
13
|
+
fields: {
|
|
14
|
+
stripeCustomerId: {type: "string", required: false}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if config.dig(:subscription, :enabled)
|
|
20
|
+
base_schema[:subscription] = {
|
|
21
|
+
fields: {
|
|
22
|
+
plan: {type: "string", required: true},
|
|
23
|
+
referenceId: {type: "string", required: true},
|
|
24
|
+
stripeCustomerId: {type: "string", required: false},
|
|
25
|
+
stripeSubscriptionId: {type: "string", required: false},
|
|
26
|
+
status: {type: "string", required: false, default_value: "incomplete"},
|
|
27
|
+
periodStart: {type: "date", required: false},
|
|
28
|
+
periodEnd: {type: "date", required: false},
|
|
29
|
+
trialStart: {type: "date", required: false},
|
|
30
|
+
trialEnd: {type: "date", required: false},
|
|
31
|
+
cancelAtPeriodEnd: {type: "boolean", required: false, default_value: false},
|
|
32
|
+
cancelAt: {type: "date", required: false},
|
|
33
|
+
canceledAt: {type: "date", required: false},
|
|
34
|
+
endedAt: {type: "date", required: false},
|
|
35
|
+
seats: {type: "number", required: false},
|
|
36
|
+
billingInterval: {type: "string", required: false},
|
|
37
|
+
stripeScheduleId: {type: "string", required: false},
|
|
38
|
+
limits: {type: "json", required: false}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if config.dig(:organization, :enabled)
|
|
44
|
+
base_schema[:organization] = {fields: {stripeCustomerId: {type: "string", required: false}}}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
custom_schema = custom_schema.except(:subscription) unless config.dig(:subscription, :enabled)
|
|
48
|
+
deep_merge_schema(base_schema, custom_schema)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def custom_schema(config)
|
|
52
|
+
raw = config && (config[:schema] || config["schema"])
|
|
53
|
+
normalize_custom_schema(raw || {})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_custom_schema(value)
|
|
57
|
+
return {} unless value.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
value.each_with_object({}) do |(model_name, model_schema), result|
|
|
60
|
+
normalized_model = BetterAuth::Plugins.normalize_key(model_name)
|
|
61
|
+
result[normalized_model] = normalize_custom_model_schema(model_schema)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_custom_model_schema(value)
|
|
66
|
+
return value unless value.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
value.each_with_object({}) do |(key, object), result|
|
|
69
|
+
normalized_key = BetterAuth::Plugins.normalize_key(key)
|
|
70
|
+
result[normalized_key] = if normalized_key == :fields && object.is_a?(Hash)
|
|
71
|
+
object.each_with_object({}) do |(field_name, field_schema), fields|
|
|
72
|
+
fields[field_name] = field_schema
|
|
73
|
+
end
|
|
74
|
+
elsif object.is_a?(Hash)
|
|
75
|
+
BetterAuth::Plugins.normalize_hash(object)
|
|
76
|
+
else
|
|
77
|
+
object
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def deep_merge_schema(base, override)
|
|
83
|
+
base.merge(override) do |_key, old_value, new_value|
|
|
84
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
85
|
+
deep_merge_schema(old_value, new_value)
|
|
86
|
+
else
|
|
87
|
+
new_value
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Types
|
|
6
|
+
CUSTOMER_TYPES = %w[user organization].freeze
|
|
7
|
+
AUTHORIZE_REFERENCE_ACTIONS = %w[
|
|
8
|
+
upgrade-subscription
|
|
9
|
+
cancel-subscription
|
|
10
|
+
restore-subscription
|
|
11
|
+
billing-portal
|
|
12
|
+
list-subscriptions
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Stripe
|
|
5
|
+
module Utils
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def client(config)
|
|
9
|
+
BetterAuth::Plugins.stripe_client(config)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def id(object)
|
|
13
|
+
fetch(object, "id")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch(object, key)
|
|
17
|
+
return nil unless object.respond_to?(:[])
|
|
18
|
+
|
|
19
|
+
object[key] || object[key.to_sym]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def time(value)
|
|
23
|
+
return nil unless value
|
|
24
|
+
|
|
25
|
+
Time.at(value.to_i)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def subscription_options(config)
|
|
29
|
+
BetterAuth::Plugins.normalize_hash(config[:subscription] || {})
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def plans(config)
|
|
33
|
+
plans = subscription_options(config)[:plans] || []
|
|
34
|
+
plans = plans.call if plans.respond_to?(:call)
|
|
35
|
+
Array(plans).map do |plan|
|
|
36
|
+
normalized = BetterAuth::Plugins.normalize_hash(plan)
|
|
37
|
+
limits = fetch(plan, "limits")
|
|
38
|
+
normalized[:limits] = limits if limits
|
|
39
|
+
normalized
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def plan_by_name(config, name)
|
|
44
|
+
plans(config).find { |plan| plan[:name].to_s.downcase == name.to_s.downcase }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def plan_by_price_info(config, price_id, lookup_key = nil)
|
|
48
|
+
plans(config).find do |plan|
|
|
49
|
+
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))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def price_id(config, plan, annual = false)
|
|
54
|
+
annual ? (plan[:annual_discount_price_id] || resolve_lookup(config, plan[:annual_discount_lookup_key])) : (plan[:price_id] || resolve_lookup(config, plan[:lookup_key]))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_lookup(config, lookup_key)
|
|
58
|
+
return nil if lookup_key.to_s.empty?
|
|
59
|
+
return nil unless client(config).respond_to?(:prices)
|
|
60
|
+
|
|
61
|
+
prices = client(config).prices.list(lookup_keys: [lookup_key], active: true, limit: 1)
|
|
62
|
+
fetch(Array(fetch(prices, "data")).first || {}, "id")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def active_or_trialing?(subscription)
|
|
66
|
+
%w[active trialing].include?(fetch(subscription, "status").to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pending_cancel?(subscription)
|
|
70
|
+
!!(fetch(subscription, "cancelAtPeriodEnd") || fetch(subscription, "cancelAt"))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stripe_pending_cancel?(subscription)
|
|
74
|
+
!!(fetch(subscription, "cancel_at_period_end") || fetch(subscription, "cancel_at"))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def subscription_item(subscription)
|
|
78
|
+
Array(fetch(fetch(subscription, "items") || {}, "data")).first
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def resolve_plan_item(config, subscription)
|
|
82
|
+
items = Array(fetch(fetch(subscription, "items") || {}, "data"))
|
|
83
|
+
first = items.first
|
|
84
|
+
return nil unless first
|
|
85
|
+
|
|
86
|
+
items.each do |item|
|
|
87
|
+
price = fetch(item, "price") || {}
|
|
88
|
+
plan = plan_by_price_info(config, fetch(price, "id"), fetch(price, "lookup_key"))
|
|
89
|
+
return {item: item, plan: plan} if plan
|
|
90
|
+
end
|
|
91
|
+
{item: first, plan: nil} if items.length == 1
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_quantity(subscription, plan_item, plan = nil)
|
|
95
|
+
items = Array(fetch(fetch(subscription, "items") || {}, "data"))
|
|
96
|
+
seat_price_id = plan && plan[:seat_price_id]
|
|
97
|
+
seat_item = seat_price_id && items.find { |item| fetch(fetch(item, "price") || {}, "id") == seat_price_id }
|
|
98
|
+
fetch(seat_item || plan_item, "quantity") || 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def line_item(config, price_id, quantity)
|
|
102
|
+
item = {price: price_id}
|
|
103
|
+
item[:quantity] = quantity unless metered_price?(config, price_id)
|
|
104
|
+
item
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def checkout_line_items(config, plan, price_id, quantity, auto_managed_seats, seat_only_plan)
|
|
108
|
+
items = []
|
|
109
|
+
items << line_item(config, price_id, auto_managed_seats ? 1 : quantity) unless seat_only_plan
|
|
110
|
+
items << {price: plan[:seat_price_id], quantity: quantity} if auto_managed_seats && plan[:seat_price_id]
|
|
111
|
+
items.concat(plan_line_items(plan))
|
|
112
|
+
items
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def plan_line_items(plan)
|
|
116
|
+
Array(plan[:line_items]).map do |item|
|
|
117
|
+
item.is_a?(Hash) ? BetterAuth::Plugins.normalize_hash(item) : item
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def direct_subscription_update?(old_plan, plan, auto_managed_seats)
|
|
122
|
+
return true if auto_managed_seats && old_plan && old_plan[:seat_price_id] != plan[:seat_price_id]
|
|
123
|
+
|
|
124
|
+
plan_line_items(old_plan || {}).map { |item| item[:price] } != plan_line_items(plan).map { |item| item[:price] }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def metered_price?(config, price_id, lookup_key = nil)
|
|
128
|
+
price = resolve_stripe_price(config, price_id, lookup_key)
|
|
129
|
+
recurring = fetch(price || {}, "recurring") || {}
|
|
130
|
+
fetch(recurring, "usage_type") == "metered"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def resolve_stripe_price(config, price_id, lookup_key = nil)
|
|
134
|
+
return nil unless client(config).respond_to?(:prices)
|
|
135
|
+
|
|
136
|
+
prices = client(config).prices
|
|
137
|
+
if lookup_key
|
|
138
|
+
result = prices.list(lookup_keys: [lookup_key], active: true, limit: 1)
|
|
139
|
+
Array(fetch(result, "data")).first
|
|
140
|
+
elsif price_id && prices.respond_to?(:retrieve)
|
|
141
|
+
prices.retrieve(price_id)
|
|
142
|
+
end
|
|
143
|
+
rescue
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def subscription_state(subscription, include_status: true, compact: true)
|
|
148
|
+
item = subscription_item(subscription)
|
|
149
|
+
price = fetch(item || {}, "price") || {}
|
|
150
|
+
recurring = fetch(price, "recurring") || {}
|
|
151
|
+
state = {
|
|
152
|
+
periodStart: time(fetch(item || subscription, "current_period_start")),
|
|
153
|
+
periodEnd: time(fetch(item || subscription, "current_period_end")),
|
|
154
|
+
cancelAtPeriodEnd: fetch(subscription, "cancel_at_period_end"),
|
|
155
|
+
cancelAt: time(fetch(subscription, "cancel_at")),
|
|
156
|
+
canceledAt: time(fetch(subscription, "canceled_at")),
|
|
157
|
+
endedAt: time(fetch(subscription, "ended_at")),
|
|
158
|
+
trialStart: time(fetch(subscription, "trial_start")),
|
|
159
|
+
trialEnd: time(fetch(subscription, "trial_end")),
|
|
160
|
+
billingInterval: fetch(recurring, "interval"),
|
|
161
|
+
stripeScheduleId: schedule_id(subscription)
|
|
162
|
+
}
|
|
163
|
+
state[:status] = fetch(subscription, "status") if include_status
|
|
164
|
+
compact ? state.compact : state
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def schedule_id(subscription)
|
|
168
|
+
schedule = fetch(subscription, "schedule")
|
|
169
|
+
return nil if schedule.nil?
|
|
170
|
+
return schedule if schedule.is_a?(String)
|
|
171
|
+
|
|
172
|
+
id(schedule) || schedule.to_s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def redirect?(body)
|
|
176
|
+
body[:disable_redirect] != true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def url(ctx, url)
|
|
180
|
+
return url if url.to_s.match?(/\A[a-zA-Z][a-zA-Z0-9+\-.]*:/)
|
|
181
|
+
|
|
182
|
+
"#{ctx.context.base_url}#{url.to_s.start_with?("/") ? url : "/#{url}"}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def escape_search(value)
|
|
186
|
+
value.to_s.gsub("\"", "\\\"")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|