main
rb 127 lines 4.46 KB
Raw
1 # frozen_string_literal: true
2
3 # siGit's Stripe integration for siGit Code Pro/Team. Stripe is the source of
4 # truth; we cache plan + status into Subscription so the cloud gate decides
5 # without a Stripe round-trip. Entirely separate from Onde Inference's billing.
6 class StripeService
7 # Our plan → the Stripe price lookup_key (prices created in Stripe).
8 PRICE_LOOKUP_KEYS = {
9 "pro" => "sigit_code_pro_monthly",
10 "team" => "sigit_code_team_monthly"
11 }.freeze
12
13 # Free-trial length for new subscriptions. Card is collected at checkout and
14 # Stripe auto-converts to paid when the trial ends. Tunable GTM knob.
15 TRIAL_DAYS = 14
16
17 # A subscription's price lookup_key → our plan.
18 PLAN_BY_LOOKUP_KEY = {
19 "sigit_code_pro_monthly" => :pro,
20 "sigit_code_team_monthly" => :team
21 }.freeze
22
23 # Stripe subscription status → our Subscription status enum.
24 STATUS_MAP = {
25 "active" => :active,
26 "trialing" => :trialing,
27 "past_due" => :past_due,
28 "unpaid" => :past_due,
29 "canceled" => :canceled,
30 "incomplete_expired" => :canceled,
31 "incomplete" => :incomplete,
32 "paused" => :incomplete
33 }.freeze
34
35 class << self
36 def configured?
37 secret_key.present?
38 end
39
40 # Stripe secret key — from Rails credentials (`stripe.secret_key`), falling
41 # back to ENV for local dev. Rotating the key in Stripe revokes the old one,
42 # so keep this value current or Checkout fails with "Expired API Key".
43 def secret_key
44 Rails.application.credentials.dig(:stripe, :secret_key).presence ||
45 ENV["STRIPE_SECRET_KEY"].presence
46 end
47
48 # Webhook signing secret, same credentials-then-ENV resolution.
49 def webhook_secret
50 Rails.application.credentials.dig(:stripe, :webhook_secret).presence ||
51 ENV["STRIPE_WEBHOOK_SECRET"].presence
52 end
53
54 # Create a Checkout session for a user + plan; returns the redirect URL.
55 def checkout_url(user:, plan:, success_url:, cancel_url:)
56 price_id = price_id_for(plan)
57 raise ArgumentError, "Unknown plan #{plan}" unless price_id
58
59 existing_customer = user.subscription&.stripe_customer_id
60
61 session = Stripe::Checkout::Session.create({
62 mode: "subscription",
63 line_items: [ { price: price_id, quantity: 1 } ],
64 client_reference_id: user.id.to_s,
65 customer: existing_customer,
66 customer_email: existing_customer ? nil : user.email,
67 allow_promotion_codes: true,
68 success_url: success_url,
69 cancel_url: cancel_url,
70 metadata: { user_id: user.id.to_s },
71 subscription_data: {
72 metadata: { user_id: user.id.to_s },
73 trial_period_days: TRIAL_DAYS
74 }
75 }.compact)
76
77 session.url
78 end
79
80 # Create a Billing Portal session for a user; returns the URL. nil when the
81 # user has no Stripe customer yet (never subscribed).
82 def portal_url(user:, return_url:)
83 customer = user.subscription&.stripe_customer_id
84 return nil unless customer
85
86 Stripe::BillingPortal::Session.create(customer: customer, return_url: return_url).url
87 end
88
89 # Verify + parse a webhook payload into a Stripe::Event.
90 def construct_event(payload, signature_header)
91 secret = webhook_secret or raise KeyError, "Missing Stripe webhook secret"
92 Stripe::Webhook.construct_event(payload, signature_header, secret)
93 end
94
95 # Upsert the local Subscription from a Stripe subscription object. Returns the
96 # Subscription, or nil when the subscription isn't attributable to a user.
97 def sync_subscription(stripe_subscription)
98 user_id = stripe_subscription.metadata && stripe_subscription.metadata["user_id"]
99 user = User.find_by(id: user_id)
100 return nil unless user
101
102 price = stripe_subscription.items&.data&.first&.price
103 plan = PLAN_BY_LOOKUP_KEY[price&.lookup_key] || :free
104 status = STATUS_MAP[stripe_subscription.status] || :incomplete
105 period_end = stripe_subscription.current_period_end
106
107 subscription = user.subscription || user.build_subscription
108 subscription.update!(
109 plan: plan,
110 status: status,
111 stripe_customer_id: stripe_subscription.customer,
112 stripe_subscription_id: stripe_subscription.id,
113 current_period_end: period_end ? Time.at(period_end) : nil
114 )
115 subscription
116 end
117
118 private
119
120 def price_id_for(plan)
121 lookup_key = PRICE_LOOKUP_KEYS[plan.to_s]
122 return nil unless lookup_key
123
124 Stripe::Price.list(lookup_keys: [ lookup_key ], active: true, limit: 1).data.first&.id
125 end
126 end
127 end