berrypod/lib/simpleshop_theme/stripe/setup.ex
jamey eede9bb517 feat: add encrypted settings, guided Stripe setup, and admin credentials page
Store API keys and secrets encrypted in the SQLite database via the
existing Vault module (AES-256-GCM). The only external dependency is
SECRET_KEY_BASE — everything else lives in the portable DB file.

- Add encrypted_value column to settings table with new "encrypted" type
- Add put_secret/get_secret/delete_setting/secret_hint to Settings context
- Add Secrets module to load encrypted config into Application env at startup
- Add Stripe.Setup module with connect/disconnect/verify_api_key flow
  - Auto-creates webhook endpoints via Stripe API in production
  - Detects localhost and shows Stripe CLI instructions for dev
- Add admin credentials page at /admin/settings with guided setup:
  - Not configured: single Secret key input with dashboard link
  - Connected (production): status display, webhook info, disconnect
  - Connected (dev): Stripe CLI instructions, manual signing secret input
- Remove Stripe env vars from dev.exs and runtime.exs
- Fix CSSCache test startup crash (handle_continue instead of init)
- Add nav link for Credentials page

507 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:12:53 +00:00

136 lines
3.9 KiB
Elixir

defmodule SimpleshopTheme.Stripe.Setup do
@moduledoc """
Handles Stripe account setup: key verification, automatic webhook
endpoint creation, and teardown.
"""
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Secrets
require Logger
@webhook_events ["checkout.session.completed", "checkout.session.expired"]
@doc """
Verifies a Stripe API key by making a lightweight Balance API call.
"""
def verify_api_key(api_key) do
case Stripe.Balance.retrieve(%{}, api_key: api_key) do
{:ok, _balance} -> :ok
{:error, %Stripe.Error{message: message}} -> {:error, message}
{:error, _} -> {:error, "Could not connect to Stripe"}
end
end
@doc """
Full setup flow: verify key, store it, create webhook endpoint if possible.
Returns:
- `{:ok, :webhook_created}` — key valid, webhook auto-created (production)
- `{:ok, :localhost}` — key valid, but URL is localhost so webhook skipped
- `{:error, message}` — key invalid or setup failed
"""
def connect(api_key) do
with :ok <- verify_api_key(api_key) do
Settings.put_secret("stripe_api_key", api_key)
case maybe_create_webhook(api_key) do
{:ok, result} ->
Secrets.load_stripe_config()
{:ok, result}
{:error, reason} ->
# Key is valid and stored, but webhook creation failed.
# Still load the key so checkout works (webhooks can be set up manually).
Secrets.load_stripe_config()
{:error, reason}
end
end
end
@doc """
Removes Stripe configuration and deletes the webhook endpoint from Stripe.
"""
def disconnect do
delete_existing_webhook()
for key <- ["stripe_api_key", "stripe_signing_secret", "stripe_webhook_endpoint_id"] do
Settings.delete_setting(key)
end
Application.delete_env(:stripity_stripe, :api_key)
Application.delete_env(:stripity_stripe, :signing_secret)
:ok
end
@doc """
Saves a manually-provided webhook signing secret (for dev mode / Stripe CLI).
"""
def save_signing_secret(signing_secret) do
Settings.put_secret("stripe_signing_secret", signing_secret)
Secrets.load_stripe_config()
end
@doc """
Returns the webhook URL for this app.
"""
def webhook_url do
"#{SimpleshopThemeWeb.Endpoint.url()}/webhooks/stripe"
end
@doc """
Returns true if the app is running on localhost (Stripe can't reach it).
"""
def localhost? do
url = SimpleshopThemeWeb.Endpoint.url()
uri = URI.parse(url)
uri.host in ["localhost", "127.0.0.1", "0.0.0.0", "::1"]
end
defp maybe_create_webhook(api_key) do
if localhost?() do
{:ok, :localhost}
else
delete_existing_webhook()
create_webhook(api_key)
end
end
defp create_webhook(api_key) do
params = %{
url: webhook_url(),
enabled_events: @webhook_events
}
case Stripe.WebhookEndpoint.create(params, api_key: api_key) do
{:ok, endpoint} ->
Settings.put_secret("stripe_signing_secret", endpoint.secret)
Settings.put_setting("stripe_webhook_endpoint_id", endpoint.id, "string")
Logger.info("Stripe webhook endpoint created: #{endpoint.id}")
{:ok, :webhook_created}
{:error, %Stripe.Error{message: message}} ->
Logger.warning("Failed to create Stripe webhook: #{message}")
{:error, message}
{:error, _} ->
{:error, "Failed to create webhook endpoint"}
end
end
defp delete_existing_webhook do
endpoint_id = Settings.get_setting("stripe_webhook_endpoint_id")
api_key = Settings.get_secret("stripe_api_key")
if endpoint_id && api_key do
case Stripe.WebhookEndpoint.delete(endpoint_id, api_key: api_key) do
{:ok, _} ->
Logger.info("Deleted Stripe webhook endpoint: #{endpoint_id}")
{:error, reason} ->
Logger.warning("Failed to delete webhook endpoint #{endpoint_id}: #{inspect(reason)}")
end
end
end
end