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,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