fix(billing): read Stripe keys from Rails credentials; rotate to valid key

Migrate STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET out of the server .profile and into Rails credentials (decrypted on the box by its existing master key). The production live key had been rolled in Stripe and revoked, so Checkout failed with "Expired API Key"; credentials now holds a valid restricted key scoped to recurring subscriptions + billing. The initializer and StripeService read credentials first, ENV second (dev fallback). Verified the new key resolves sigit_code_pro_monthly / _team_monthly and creates a subscription Checkout Session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 22:18 UTC 2b675c1e3b9ef96cd7570b689fe06d5520abf448
3 files changed +25 -4
app/services/stripe_service.rb
+17 -2
@@ -34,7 +34,21 @@ class StripeService
34
35 class << self
36 def configured?
37 - ENV["STRIPE_SECRET_KEY"].present?
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.
@@ -74,7 +88,8 @@ class StripeService
88
89 # Verify + parse a webhook payload into a Stripe::Event.
90 def construct_event(payload, signature_header)
77 - Stripe::Webhook.construct_event(payload, signature_header, ENV.fetch("STRIPE_WEBHOOK_SECRET"))
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
config/credentials.yml.enc
+1 -1
@@ -1 +1 @@
1 -4FBLgT4lqYn6Xq573rcAdqYW8uoCip65xOaJcs9uI4+4GjgUy6mVxbbs9drNMkeR/hT1bmWxcc2yGahuZ4HCnCTHongcYSeRN+tq3ZKK6uBRtJ5n4VoF8bb79Y11XzpWYJRzI4+qI9XIwNBiCgB0LrLjJE1+1OPrphA26JBYPGq1CuepuIC1uhF6tRBTj3+mxjMLb9TZK/xnQPttlhPCryt4IC6Rl1223Yj7Hp3J4OKOOYisuK7PfpO/1HhCCnHVffeWfjOA+bunX0CMycaPQPNNgzEDQNy+3sr6pCRDU4PmIlQv2ED4ZNoldIyrdqvvXj7cyWmj8qe5nhoRRfskaLm3Pm6abN29DFxw9AnU/z4EXUCrcnd+dhfLPd2rW5X3qWGgHwRMounOc+HWHFR4snJR9gNAGbNrBZt9kfRPinB+pJDNVpvsKX+RUDsZ3HamouD4t951pc6MdzzSsAOYLXyeoNxkgK9pHAvfSHbGxnPwNR4X03wjhK/8--2sl/oC/jkTHhawD8--aK/HddPgwwngDeBQyxNY3g==
\ No newline at end of file
1 ++uuzge/fz5cak/4I5S2evwZeYm5qu/3zz94MxEpTo72a4X/Yuyn5xKULDCdjT65UFu4CYadvWbHtPBftrgxFIqRhniwaGkRpT/2z3ZAWAycU8qkNgkEtpy7Azu1OoVs4/IHJuTSklfh5cp3U9rkwQCe56Zn2pElfF8vzGsE1zo2AVdLdUCEmLrbHSX4yKPhauAR8Q1gh7KyuHkz+rPzXSclnNbDDZsv9iH8Ufao8YsAFzv6SvrI5vRNGR9SQXkLi42H6KaKoWIFlPCauiQH8l76Ot9YuJtVEEQ9schWQLrWpsvi+6oh+DRQjcmBTUhP5K5ge6qmlg+glaqMcfTuij50U/MPtjfwhizt4mg3qy3dNpuzen2Ginu1cWDCTCW2C25B0m1D4zLej4kEjoRqFjV61xwnly9fm51mj3l5xC3qppmj0JLf3BodLvxYTIQYCK7JNCKbUhXLnNdVStw==--fZoss+4YG+UXitvb--DS63/363pfwindrZHsFAjQ==
\ No newline at end of file
config/initializers/stripe.rb
+7 -1
@@ -2,4 +2,10 @@
2
3 # siGit's own Stripe API key (siGit Code Pro/Team). Separate product line from
4 # Onde Inference's billing, though it may share the Stripe account.
5 -Stripe.api_key = ENV["STRIPE_SECRET_KEY"] if ENV["STRIPE_SECRET_KEY"].present?
5 +#
6 +# Source of truth is Rails credentials (`stripe.secret_key`); ENV is a fallback
7 +# for local dev. (Inlined here rather than calling StripeService to avoid an
8 +# autoload dependency during boot.)
9 +stripe_key = Rails.application.credentials.dig(:stripe, :secret_key).presence ||
10 + ENV["STRIPE_SECRET_KEY"].presence
11 +Stripe.api_key = stripe_key if stripe_key