refactor: remove Stripe billing subscriptions
All checks were successful
Deploy Docker Images / Build, push, and deploy (push) Successful in 18m29s

Drop product subscriptions, paid registration checkout, admin products
UI, subscribe LiveView, webhooks, and stripity_stripe. Registration
with invites stays invite-only; social/RSS/push/IMAP subscribe paths
are unchanged. Add a migration to drop the billing tables.
This commit is contained in:
maxfield 2026-08-04 06:34:49 -04:00
parent 4708fde5a3
commit 3ab7c69b56
38 changed files with 41 additions and 4421 deletions

View file

@ -238,11 +238,12 @@ ACME_EMAIL=admin@example.com
# TURN_SHARED_SECRET=
# ATOMINE_POW_DIFFICULTY=20
# ATOMINE_POW_SKIP_VERIFICATION=false
# STRIPE removed; billing subscriptions deleted
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SECRET=
# -----------------------------------------------------------------------------
# Monero payments (public display for no-KYC registration / billing copy)
# Monero payments (public display for no-KYC registration copy)
# -----------------------------------------------------------------------------
# Set an address and/or a checkout URL (for example BTCPay Monero).
# MONERO_ENABLED=true

View file

@ -35,7 +35,6 @@ defmodule Elektrine.Accounts do
alias Elektrine.Accounts.Muting
alias Elektrine.Accounts.Subscriptions
alias Elektrine.Accounts.Tracking
alias Elektrine.Subscriptions.RegistrationCheckout
require Logger
@ -841,24 +840,18 @@ defmodule Elektrine.Accounts do
end
@doc """
Registers a new user using either a valid invite code or a fulfilled paid registration checkout.
Registers a new user using a valid invite code (when invite codes are enabled).
"""
def register_user_with_access(attrs) do
invite_code = extract_invite_code(attrs)
registration_access_token = extract_registration_access_token(attrs)
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, attrs))
|> Ecto.Multi.run(:registration_access, fn repo, %{user: user} ->
cond do
!blank_invite_code?(invite_code) ->
claim_invite_code(repo, invite_code, user.id)
!blank_registration_access_token?(registration_access_token) ->
claim_registration_checkout(repo, registration_access_token, user.id)
true ->
{:error, :invite_or_payment_required}
|> Ecto.Multi.run(:invite_use, fn repo, %{user: user} ->
if blank_invite_code?(invite_code) do
{:error, :invite_required}
else
claim_invite_code(repo, invite_code, user.id)
end
end)
|> Repo.transaction()
@ -870,8 +863,8 @@ defmodule Elektrine.Accounts do
{:error, :user, changeset, _changes_so_far} ->
{:error, changeset}
{:error, :registration_access, reason, _changes_so_far} ->
{:error, access_registration_changeset(attrs, reason)}
{:error, :invite_use, reason, _changes_so_far} ->
{:error, invite_registration_changeset(attrs, reason)}
end
end
@ -1393,48 +1386,6 @@ defmodule Elektrine.Accounts do
|> repo.insert()
end
defp claim_registration_checkout(repo, lookup_token, user_id) do
case get_registration_checkout_for_update(repo, lookup_token) do
nil ->
{:error, :invalid_registration_access}
%RegistrationCheckout{} = checkout ->
case registration_checkout_validation_error(checkout) do
nil ->
checkout
|> RegistrationCheckout.fulfill_changeset(%{
redeemed_at: DateTime.utc_now() |> DateTime.truncate(:second),
redeemed_by_user_id: user_id
})
|> repo.update()
|> case do
{:ok, updated_checkout} -> {:ok, updated_checkout}
{:error, _changeset} -> {:error, :invalid_registration_access}
end
reason ->
{:error, reason}
end
end
end
defp get_registration_checkout_for_update(repo, lookup_token) do
normalized_token = String.trim(to_string(lookup_token))
RegistrationCheckout
|> where([c], c.lookup_token == ^RegistrationCheckout.hash_lookup_token(normalized_token))
|> lock("FOR UPDATE")
|> repo.one()
end
defp registration_checkout_validation_error(%RegistrationCheckout{} = checkout) do
cond do
checkout.status != "fulfilled" -> :registration_payment_pending
not is_nil(checkout.redeemed_at) -> :registration_access_already_used
true -> nil
end
end
defp increment_invite_code_usage(repo, invite_code_id) do
from(i in InviteCode,
where: i.id == ^invite_code_id and i.uses_count < i.max_uses
@ -1462,30 +1413,6 @@ defmodule Elektrine.Accounts do
|> Ecto.Changeset.add_error(:invite_code, invite_code_error_message(reason))
end
defp access_registration_changeset(attrs, reason) do
changeset = User.registration_changeset(%User{}, attrs)
case reason do
reason
when reason in [
:invalid_code,
:code_expired,
:code_exhausted,
:code_inactive,
:monthly_invite_use_limit_reached,
:already_used
] ->
Ecto.Changeset.add_error(changeset, :invite_code, invite_code_error_message(reason))
_ ->
Ecto.Changeset.add_error(
changeset,
:registration_access_token,
registration_access_error_message(reason)
)
end
end
defp invite_code_error_message(:invalid_code), do: "Invalid invite code"
defp invite_code_error_message(:code_expired), do: "This invite code has expired"
@ -1500,39 +1427,19 @@ defmodule Elektrine.Accounts do
defp invite_code_error_message(:already_used),
do: "This account has already used an invite code"
defp invite_code_error_message(:invite_required),
do: "An invite code is required to create an account"
defp invite_code_error_message(_reason), do: "Invalid invite code"
defp registration_access_error_message(:registration_payment_pending),
do: "Your payment is still being confirmed. Please refresh and try again in a moment"
defp registration_access_error_message(:registration_access_already_used),
do: "This paid registration access has already been used"
defp registration_access_error_message(:invite_or_payment_required),
do: "An invite code or paid access is required to create an account"
defp registration_access_error_message(:invalid_registration_access),
do: "Paid registration access could not be verified"
defp registration_access_error_message(_reason),
do: "An invite code or paid access is required to create an account"
defp extract_invite_code(attrs) when is_map(attrs) do
Map.get(attrs, "invite_code") || Map.get(attrs, :invite_code)
end
defp extract_registration_access_token(attrs) when is_map(attrs) do
Map.get(attrs, "registration_access_token") || Map.get(attrs, :registration_access_token)
end
defp blank_invite_code?(code) do
is_nil(code) or not Elektrine.Strings.present?(to_string(code))
end
defp blank_registration_access_token?(token) do
is_nil(token) or not Elektrine.Strings.present?(to_string(token))
end
defp ensure_self_service_invites_enabled do
if Elektrine.System.invite_codes_enabled?() do
:ok

View file

@ -1,6 +1,6 @@
defmodule Elektrine.Payments.Crypto do
@moduledoc """
Public crypto payment settings for registration and billing copy.
Public crypto payment settings for registration copy.
Configure with environment variables (see `config/runtime.exs`):

File diff suppressed because it is too large Load diff

View file

@ -1,230 +0,0 @@
defmodule Elektrine.Subscriptions.Product do
@moduledoc """
Schema for subscription products.
Products are managed via admin panel and define what users can subscribe to.
Products can be billed as recurring subscriptions or one-time purchases.
"""
use Ecto.Schema
import Ecto.Changeset
@billing_types ~w(recurring one_time)
schema "subscription_products" do
field :name, :string
field :slug, :string
field :description, :string
field :features, {:array, :string}, default: []
field :billing_type, :string, default: "recurring"
field :stripe_monthly_price_id, :string
field :stripe_yearly_price_id, :string
field :stripe_one_time_price_id, :string
field :monthly_price_cents, :integer
field :yearly_price_cents, :integer
field :one_time_price_cents, :integer
field :currency, :string, default: "usd"
field :active, :boolean, default: true
field :sort_order, :integer, default: 0
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating a product.
"""
def create_changeset(product, attrs) do
product
|> cast(attrs, [
:name,
:slug,
:description,
:features,
:billing_type,
:stripe_monthly_price_id,
:stripe_yearly_price_id,
:stripe_one_time_price_id,
:monthly_price_cents,
:yearly_price_cents,
:one_time_price_cents,
:currency,
:active,
:sort_order
])
|> normalize_string_fields()
|> validate_required([:name, :slug])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:slug, min: 1, max: 50)
|> validate_format(:slug, ~r/^[a-z0-9-]+$/,
message: "only lowercase letters, numbers, and hyphens"
)
|> validate_inclusion(:billing_type, @billing_types)
|> validate_number(:monthly_price_cents, greater_than_or_equal_to: 0)
|> validate_number(:yearly_price_cents, greater_than_or_equal_to: 0)
|> validate_number(:one_time_price_cents, greater_than_or_equal_to: 0)
|> validate_billing_type_pricing()
|> unique_constraint(:slug)
end
@doc """
Changeset for updating an existing product.
"""
def update_changeset(product, attrs) do
product
|> cast(attrs, [
:name,
:description,
:features,
:billing_type,
:stripe_monthly_price_id,
:stripe_yearly_price_id,
:stripe_one_time_price_id,
:monthly_price_cents,
:yearly_price_cents,
:one_time_price_cents,
:currency,
:active,
:sort_order
])
|> normalize_string_fields()
|> reject_slug_change(attrs)
|> validate_required([:name, :slug])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:slug, min: 1, max: 50)
|> validate_format(:slug, ~r/^[a-z0-9-]+$/,
message: "only lowercase letters, numbers, and hyphens"
)
|> validate_inclusion(:billing_type, @billing_types)
|> validate_number(:monthly_price_cents, greater_than_or_equal_to: 0)
|> validate_number(:yearly_price_cents, greater_than_or_equal_to: 0)
|> validate_number(:one_time_price_cents, greater_than_or_equal_to: 0)
|> validate_billing_type_pricing()
|> unique_constraint(:slug)
end
defp normalize_string_fields(changeset) do
changeset
|> update_change(:name, &normalize_optional_string/1)
|> update_change(:slug, &normalize_optional_string/1)
|> update_change(:description, &normalize_optional_string/1)
|> update_change(:billing_type, &normalize_optional_string/1)
|> update_change(:stripe_monthly_price_id, &normalize_optional_string/1)
|> update_change(:stripe_yearly_price_id, &normalize_optional_string/1)
|> update_change(:stripe_one_time_price_id, &normalize_optional_string/1)
|> update_change(:currency, &normalize_optional_string/1)
end
defp validate_billing_type_pricing(changeset) do
case get_field(changeset, :billing_type) do
"one_time" ->
if recurring_pricing_present?(changeset) do
add_error(
changeset,
:billing_type,
"one-time products cannot include monthly or yearly pricing"
)
else
changeset
end
_ ->
if one_time_pricing_present?(changeset) do
add_error(
changeset,
:billing_type,
"recurring products cannot include one-time pricing"
)
else
changeset
end
end
end
defp recurring_pricing_present?(changeset) do
present?(get_field(changeset, :stripe_monthly_price_id)) or
present?(get_field(changeset, :stripe_yearly_price_id)) or
not is_nil(get_field(changeset, :monthly_price_cents)) or
not is_nil(get_field(changeset, :yearly_price_cents))
end
defp one_time_pricing_present?(changeset) do
present?(get_field(changeset, :stripe_one_time_price_id)) or
not is_nil(get_field(changeset, :one_time_price_cents))
end
defp present?(value) when is_binary(value), do: Elektrine.Strings.present?(value)
defp present?(value), do: not is_nil(value)
defp reject_slug_change(changeset, attrs) do
case attr_value(attrs, :slug) do
nil ->
changeset
slug when slug == changeset.data.slug ->
changeset
_ ->
add_error(changeset, :slug, "cannot be changed after creation")
end
end
defp attr_value(attrs, key) when is_atom(key) do
Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key))
end
defp normalize_optional_string(value) when is_binary(value),
do: Elektrine.Strings.present(value)
defp normalize_optional_string(value), do: value
@doc """
Format price in cents as a display string.
"""
def format_price(nil, _currency), do: nil
def format_price(cents, currency) when is_integer(cents) do
dollars = cents / 100
symbol = currency_symbol(currency)
"#{symbol}#{:erlang.float_to_binary(dollars, decimals: 2)}"
end
def billing_types, do: @billing_types
def recurring?(%__MODULE__{billing_type: "one_time"}), do: false
def recurring?(%__MODULE__{}), do: true
def recurring?(_), do: false
def one_time?(%__MODULE__{billing_type: "one_time"}), do: true
def one_time?(_), do: false
defp currency_symbol("usd"), do: "$"
defp currency_symbol("eur"), do: "EUR "
defp currency_symbol("gbp"), do: "GBP "
defp currency_symbol(_), do: ""
@doc """
Check if product has monthly pricing configured.
"""
def has_monthly?(%__MODULE__{billing_type: "recurring", stripe_monthly_price_id: id})
when is_binary(id) and id != "",
do: true
def has_monthly?(_), do: false
@doc """
Check if product has yearly pricing configured.
"""
def has_yearly?(%__MODULE__{billing_type: "recurring", stripe_yearly_price_id: id})
when is_binary(id) and id != "",
do: true
def has_yearly?(_), do: false
@doc """
Check if product has one-time pricing configured.
"""
def has_one_time?(%__MODULE__{billing_type: "one_time", stripe_one_time_price_id: id})
when is_binary(id) and id != "",
do: true
def has_one_time?(_), do: false
end

View file

@ -1,67 +0,0 @@
defmodule Elektrine.Subscriptions.RegistrationCheckout do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Elektrine.Accounts.User
@statuses ~w(pending fulfilled)
schema "registration_checkouts" do
field :stripe_checkout_session_id, :string
field :lookup_token, :string
field :plain_lookup_token, :string, virtual: true
field :product_slug, :string
field :stripe_customer_id, :string
field :stripe_payment_intent_id, :string
field :customer_email, :string
field :status, :string, default: "pending"
field :fulfilled_at, :utc_datetime
field :redeemed_at, :utc_datetime
belongs_to :invite_code, Elektrine.Accounts.InviteCode
belongs_to :redeemed_by_user, Elektrine.Accounts.User
timestamps(type: :utc_datetime)
end
def create_changeset(checkout, attrs) do
checkout
|> cast(attrs, [
:stripe_checkout_session_id,
:lookup_token,
:product_slug,
:stripe_customer_id,
:stripe_payment_intent_id,
:customer_email,
:status,
:fulfilled_at,
:invite_code_id,
:redeemed_at,
:redeemed_by_user_id
])
|> validate_required([:stripe_checkout_session_id, :lookup_token, :product_slug, :status])
|> validate_inclusion(:status, @statuses)
|> unique_constraint(:stripe_checkout_session_id)
|> unique_constraint(:lookup_token)
end
def fulfill_changeset(checkout, attrs) do
checkout
|> cast(attrs, [
:stripe_customer_id,
:stripe_payment_intent_id,
:customer_email,
:status,
:fulfilled_at,
:invite_code_id,
:redeemed_at,
:redeemed_by_user_id
])
|> validate_required([:status])
|> validate_inclusion(:status, @statuses)
end
def hash_lookup_token(token) when is_binary(token), do: User.hash_sensitive_token(token)
end

View file

@ -1,9 +0,0 @@
defmodule Elektrine.Subscriptions.StripeClient do
@moduledoc false
@callback create_customer(map()) :: {:ok, map()} | {:error, term()}
@callback create_checkout_session(map()) :: {:ok, map()} | {:error, term()}
@callback create_billing_portal_session(map()) :: {:ok, map()} | {:error, term()}
@callback update_subscription(binary(), map()) :: {:ok, map()} | {:error, term()}
@callback retrieve_price(binary()) :: {:ok, map()} | {:error, term()}
end

View file

@ -1,20 +0,0 @@
defmodule Elektrine.Subscriptions.StripeClient.Live do
@moduledoc false
@behaviour Elektrine.Subscriptions.StripeClient
@impl true
def create_customer(params), do: Stripe.Customer.create(params)
@impl true
def create_checkout_session(params), do: Stripe.Checkout.Session.create(params)
@impl true
def create_billing_portal_session(params), do: Stripe.BillingPortal.Session.create(params)
@impl true
def update_subscription(subscription_id, params),
do: Stripe.Subscription.update(subscription_id, params)
@impl true
def retrieve_price(price_id), do: Stripe.Price.retrieve(price_id)
end

View file

@ -1,102 +0,0 @@
defmodule Elektrine.Subscriptions.Subscription do
@moduledoc """
Schema for user subscriptions.
This is a universal subscription system that can be used for any product.
Each subscription tracks a user's access to a specific product.
Products are managed via admin panel in the subscription_products table.
"""
use Ecto.Schema
import Ecto.Changeset
@statuses ~w(
incomplete
incomplete_expired
trialing
active
past_due
canceled
unpaid
paused
)
schema "subscriptions" do
field :product, :string
field :stripe_customer_id, :string
field :stripe_subscription_id, :string
field :stripe_price_id, :string
field :status, :string, default: "incomplete"
field :current_period_start, :utc_datetime
field :current_period_end, :utc_datetime
field :canceled_at, :utc_datetime
field :cancel_at_period_end, :boolean, default: false
field :metadata, :map, default: %{}
belongs_to :user, Elektrine.Accounts.User
timestamps(type: :utc_datetime)
end
@doc """
Returns list of valid subscription statuses.
"""
def statuses, do: @statuses
@doc """
Returns statuses that grant access to the product.
"""
def active_statuses, do: ~w(active trialing)
@doc """
Changeset for creating a new subscription.
"""
def create_changeset(subscription, attrs) do
subscription
|> cast(attrs, [
:user_id,
:product,
:stripe_customer_id,
:stripe_subscription_id,
:stripe_price_id,
:status,
:current_period_start,
:current_period_end,
:canceled_at,
:cancel_at_period_end,
:metadata
])
|> validate_required([:user_id, :product])
|> validate_length(:product, min: 1, max: 50)
|> validate_inclusion(:status, @statuses)
|> unique_constraint([:user_id, :product])
|> unique_constraint(:stripe_subscription_id)
end
@doc """
Changeset for updating subscription from Stripe webhook.
"""
def webhook_changeset(subscription, attrs) do
subscription
|> cast(attrs, [
:stripe_customer_id,
:stripe_subscription_id,
:stripe_price_id,
:status,
:current_period_start,
:current_period_end,
:canceled_at,
:cancel_at_period_end,
:metadata
])
|> validate_inclusion(:status, @statuses)
end
@doc """
Check if subscription grants access to the product.
"""
def has_access?(%__MODULE__{status: status}) do
status in active_statuses()
end
def has_access?(_), do: false
end

View file

@ -123,8 +123,7 @@ defmodule Elektrine.MixProject do
{:wallaby, "~> 0.30", only: :test, runtime: false},
{:httpoison, "~> 3.0", only: :test, override: true},
{:web_driver_client, "~> 0.3", only: :test, override: true},
{:hackney, "== 4.5.2", override: true},
{:stripity_stripe, "~> 3.3"}
{:hackney, "== 4.5.2", override: true}
]
end

View file

@ -0,0 +1,20 @@
defmodule Elektrine.Repo.Migrations.DropBillingSubscriptions do
@moduledoc """
Removes paid product/subscription billing tables (Stripe-era).
Historical migrations that created these tables remain for upgrade path.
"""
use Ecto.Migration
def up do
drop_if_exists table(:registration_checkouts)
# Stripe product subscriptions (not social/rss/push account_subscriptions)
drop_if_exists table(:subscriptions)
drop_if_exists table(:subscription_products)
end
def down do
# Irreversible by design: recreate from historical migrations if needed.
:ok
end
end

View file

@ -1,427 +0,0 @@
defmodule Elektrine.SubscriptionsTest do
use Elektrine.DataCase, async: false
alias Ecto.Changeset
alias Elektrine.Accounts.User
alias Elektrine.AccountsFixtures
alias Elektrine.Repo
alias Elektrine.Subscriptions
alias Elektrine.Subscriptions.{Product, RegistrationCheckout, Subscription}
defmodule FakeStripeClient do
@behaviour Elektrine.Subscriptions.StripeClient
@impl true
def create_customer(params), do: dispatch(:create_customer, params)
@impl true
def create_checkout_session(params), do: dispatch(:create_checkout_session, params)
@impl true
def create_billing_portal_session(params),
do: dispatch(:create_billing_portal_session, params)
@impl true
def update_subscription(subscription_id, params),
do: dispatch(:update_subscription, {subscription_id, params})
@impl true
def retrieve_price(price_id), do: dispatch(:retrieve_price, price_id)
defp dispatch(name, payload) do
case Process.get({__MODULE__, name}) do
fun when is_function(fun, 1) -> fun.(payload)
nil -> raise "missing fake Stripe expectation for #{inspect(name)}"
end
end
end
setup do
previous_client = Application.get_env(:elektrine, :stripe_client)
Application.put_env(:elektrine, :stripe_client, FakeStripeClient)
for key <- [
:create_customer,
:create_checkout_session,
:create_billing_portal_session,
:update_subscription,
:retrieve_price
] do
Process.put(
{FakeStripeClient, key},
fn _payload -> flunk("unexpected Stripe client call for #{inspect(key)}") end
)
end
on_exit(fn ->
if previous_client do
Application.put_env(:elektrine, :stripe_client, previous_client)
else
Application.delete_env(:elektrine, :stripe_client)
end
end)
:ok
end
test "create_product syncs pricing and currency from Stripe price IDs" do
expect_stripe(:retrieve_price, fn
"price_month" ->
{:ok, %{unit_amount: 1900, currency: "usd", recurring: %{interval: "month"}}}
"price_year" ->
{:ok, %{unit_amount: 19_000, currency: "usd", recurring: %{interval: "year"}}}
end)
attrs = %{
"name" => "VPN",
"slug" => "vpn",
"stripe_monthly_price_id" => "price_month",
"stripe_yearly_price_id" => "price_year",
"monthly_price_cents" => "1",
"yearly_price_cents" => "2",
"currency" => "eur"
}
assert {:ok, product} = Subscriptions.create_product(attrs)
assert product.monthly_price_cents == 1900
assert product.yearly_price_cents == 19_000
assert product.currency == "usd"
end
test "create_product syncs one-time pricing from Stripe" do
expect_stripe(:retrieve_price, fn
"price_once" ->
{:ok, %{unit_amount: 500, currency: "usd", recurring: nil}}
end)
attrs = %{
"name" => "Registration",
"slug" => "registration",
"billing_type" => "one_time",
"stripe_one_time_price_id" => "price_once",
"one_time_price_cents" => "1",
"currency" => "eur"
}
assert {:ok, product} = Subscriptions.create_product(attrs)
assert product.billing_type == "one_time"
assert product.one_time_price_cents == 500
assert product.currency == "usd"
end
test "create_product rejects mixed recurring and one-time pricing" do
assert {:error, changeset} =
Subscriptions.create_product(%{
"name" => "Registration",
"slug" => "registration",
"billing_type" => "one_time",
"monthly_price_cents" => "100"
})
assert "one-time products cannot include monthly or yearly pricing" in errors_on(changeset).billing_type
end
test "update_product rejects slug changes" do
product =
Repo.insert!(%Product{
name: "VPN",
slug: "vpn",
currency: "usd",
active: true
})
assert {:error, changeset} =
Subscriptions.update_product(product, %{
"name" => "VPN Plus",
"slug" => "vpn-plus"
})
assert "cannot be changed after creation" in errors_on(changeset).slug
end
test "delete_product blocks products with subscription history" do
user = AccountsFixtures.user_fixture()
product =
Repo.insert!(%Product{
name: "VPN",
slug: "vpn",
currency: "usd",
active: true
})
Repo.insert!(%Subscription{
user_id: user.id,
product: product.slug,
stripe_customer_id: "cus_existing",
status: "active"
})
assert {:error, :has_subscriptions} = Subscriptions.delete_product(product)
end
test "get_or_create_stripe_customer stores the Stripe customer on the user" do
user =
AccountsFixtures.user_fixture(%{username: "billinguser"})
|> then(fn user ->
user
|> Changeset.change(
recovery_email: "billing@example.com",
recovery_email_verified: true
)
|> Repo.update!()
end)
expect_stripe(:create_customer, fn params ->
assert params.email == "billing@example.com"
assert params.metadata.user_id == Integer.to_string(user.id)
{:ok, %{id: "cus_new"}}
end)
assert {:ok, "cus_new"} = Subscriptions.get_or_create_stripe_customer(user, "vpn")
assert Repo.get!(User, user.id).stripe_customer_id == "cus_new"
end
test "get_or_create_stripe_customer reuses a single existing customer ID" do
user = AccountsFixtures.user_fixture()
Repo.insert!(%Subscription{
user_id: user.id,
product: "mail",
stripe_customer_id: "cus_existing",
status: "active"
})
assert {:ok, "cus_existing"} = Subscriptions.get_or_create_stripe_customer(user, "vpn")
assert Repo.get!(User, user.id).stripe_customer_id == "cus_existing"
end
test "create_checkout_session uses the subscribe success query param by default" do
user =
AccountsFixtures.user_fixture()
|> then(fn user ->
user
|> Changeset.change(stripe_customer_id: "cus_checkout")
|> Repo.update!()
end)
expect_stripe(:create_checkout_session, fn params ->
assert params.customer == "cus_checkout"
assert params.success_url == "#{expected_base_url()}/subscribe/vpn?success=true"
assert params.cancel_url == "#{expected_base_url()}/subscribe/vpn"
{:ok, %{url: "https://checkout.test/session"}}
end)
assert {:ok, %{url: "https://checkout.test/session"}} =
Subscriptions.create_checkout_session(user, "vpn", "price_month")
end
test "create_checkout_session supports one-time payment mode" do
user =
AccountsFixtures.user_fixture()
|> then(fn user ->
user
|> Changeset.change(stripe_customer_id: "cus_checkout")
|> Repo.update!()
end)
expect_stripe(:create_checkout_session, fn params ->
assert params.customer == "cus_checkout"
assert params.mode == "payment"
assert params.metadata.checkout_mode == "payment"
assert params.metadata.price_id == "price_once"
assert params.payment_intent_data.metadata.product == "registration"
{:ok, %{url: "https://checkout.test/session"}}
end)
assert {:ok, %{url: "https://checkout.test/session"}} =
Subscriptions.create_checkout_session(user, "registration", "price_once",
checkout_mode: :payment
)
end
test "create_registration_checkout_session creates a guest payment checkout" do
product =
Repo.insert!(%Product{
name: "Registration",
slug: "registration",
billing_type: "one_time",
currency: "usd",
active: true,
one_time_price_cents: 500,
stripe_one_time_price_id: "price_once"
})
expect_stripe(:create_checkout_session, fn params ->
assert params.mode == "payment"
assert params.customer_creation == "always"
assert params.metadata.purpose == "registration_invite"
assert params.metadata.product == product.slug
assert params.metadata.price_id == product.stripe_one_time_price_id
assert params.success_url =~
"/register/purchase/success?checkout_session_id={CHECKOUT_SESSION_ID}&access="
{:ok, %{id: "cs_reg_123", url: "https://checkout.test/registration"}}
end)
assert {:ok, %{url: "https://checkout.test/registration"}} =
Subscriptions.create_registration_checkout_session(product)
assert %RegistrationCheckout{
stripe_checkout_session_id: "cs_reg_123",
product_slug: "registration",
status: "pending"
} =
Repo.get_by!(RegistrationCheckout, stripe_checkout_session_id: "cs_reg_123")
end
test "subscription created webhook falls back to customer lookup when metadata user_id is invalid" do
user =
AccountsFixtures.user_fixture()
|> then(fn user ->
user
|> Changeset.change(stripe_customer_id: "cus_webhook")
|> Repo.update!()
end)
Repo.insert!(%Product{
name: "VPN",
slug: "vpn",
currency: "usd",
active: true,
stripe_monthly_price_id: "price_month"
})
event = %{
type: "customer.subscription.created",
data: %{
object: %{
"id" => "sub_123",
"customer" => "cus_webhook",
"status" => "active",
"metadata" => %{"user_id" => "not-an-int", "product" => "vpn"},
"items" => %{"data" => [%{"price" => %{"id" => "price_month"}}]},
"current_period_start" => 1_700_000_000,
"current_period_end" => 1_700_086_400,
"cancel_at_period_end" => false
}
}
}
assert {:ok, _subscription} = Subscriptions.process_webhook_event(event)
assert %Subscription{} = subscription = Subscriptions.get_subscription(user.id, "vpn")
assert subscription.status == "active"
assert subscription.stripe_customer_id == "cus_webhook"
assert subscription.stripe_subscription_id == "sub_123"
end
test "checkout session completed creates one-time access" do
user =
AccountsFixtures.user_fixture()
|> then(fn user ->
user
|> Changeset.change(stripe_customer_id: "cus_once")
|> Repo.update!()
end)
Repo.insert!(%Product{
name: "Registration",
slug: "registration",
billing_type: "one_time",
currency: "usd",
active: true,
one_time_price_cents: 500,
stripe_one_time_price_id: "price_once"
})
event = %{
type: "checkout.session.completed",
data: %{
object: %{
"id" => "cs_123",
"mode" => "payment",
"payment_status" => "paid",
"customer" => "cus_once",
"payment_intent" => "pi_123",
"created" => 1_700_000_000,
"metadata" => %{
"user_id" => Integer.to_string(user.id),
"product" => "registration",
"price_id" => "price_once",
"checkout_mode" => "payment"
}
}
}
}
assert {:ok, %Subscription{} = subscription} = Subscriptions.process_webhook_event(event)
assert subscription.user_id == user.id
assert subscription.product == "registration"
assert subscription.status == "active"
assert subscription.stripe_subscription_id == nil
assert subscription.stripe_price_id == "price_once"
assert subscription.metadata["billing_type"] == "one_time"
end
test "checkout session completed for registration invite creates a single-use invite" do
Repo.insert!(%Product{
name: "Registration",
slug: "registration",
billing_type: "one_time",
currency: "usd",
active: true,
one_time_price_cents: 500,
stripe_one_time_price_id: "price_once"
})
event = %{
type: "checkout.session.completed",
data: %{
object: %{
"id" => "cs_reg_456",
"mode" => "payment",
"payment_status" => "paid",
"customer" => "cus_guest",
"payment_intent" => "pi_reg_456",
"customer_email" => "payer@example.com",
"created" => 1_700_000_000,
"metadata" => %{
"purpose" => "registration_invite",
"product" => "registration",
"price_id" => "price_once",
"checkout_mode" => "payment",
"registration_lookup_token" => "lookup-token"
}
}
}
}
assert {:ok, %RegistrationCheckout{} = checkout} = Subscriptions.process_webhook_event(event)
assert checkout.status == "fulfilled"
assert checkout.lookup_token == RegistrationCheckout.hash_lookup_token("lookup-token")
assert checkout.product_slug == "registration"
assert checkout.customer_email == "payer@example.com"
assert checkout.invite_code_id
checkout = Repo.preload(checkout, :invite_code, force: true)
assert checkout.invite_code.max_uses == 1
assert checkout.invite_code.is_active
end
defp expect_stripe(name, fun) do
Process.put({FakeStripeClient, name}, fun)
end
defp expected_base_url do
endpoint = Module.concat([ElektrineWeb, Endpoint])
if Code.ensure_loaded?(endpoint) do
endpoint.url()
else
"https://#{Elektrine.Domains.instance_domain()}"
end
end
end

View file

@ -228,15 +228,6 @@ defmodule ElektrineWeb.Layouts do
{ElektrineWeb.Admin.ModerationController, :unsubscribe_stats} ->
"Unsubscribe Statistics"
{ElektrineWeb.Admin.SubscriptionsController, :index} ->
"Subscription Products"
{ElektrineWeb.Admin.SubscriptionsController, :new} ->
"New Product"
{ElektrineWeb.Admin.SubscriptionsController, :edit} ->
"Edit Product"
{ElektrineVPNWeb.Admin.VPNController, :dashboard} ->
"VPN Dashboard"

View file

@ -1,152 +0,0 @@
defmodule ElektrineWeb.Admin.SubscriptionsController do
@moduledoc """
Admin controller for managing subscription products and prices.
"""
use ElektrineWeb, :controller
alias Elektrine.Subscriptions
alias Elektrine.Subscriptions.Product
plug :put_layout, html: {ElektrineWeb.Layouts, :admin}
def index(conn, _params) do
products = Subscriptions.list_products()
render(conn, :subscriptions,
products: products,
page_title: "Subscription Products"
)
end
def new(conn, _params) do
changeset = Subscriptions.change_product(%Product{})
render(conn, :new_product, changeset: changeset, page_title: "New Product")
end
def create(conn, %{"product" => product_params}) do
# Parse features from textarea (one per line)
product_params = parse_features(product_params)
case Subscriptions.create_product(product_params) do
{:ok, _product} ->
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: ~p"/pripyat/subscriptions")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new_product, changeset: changeset, page_title: "New Product")
end
end
def edit(conn, %{"id" => id}) do
product = Subscriptions.get_product(id)
if product do
changeset = Subscriptions.change_product(product)
render(conn, :edit_product,
product: product,
changeset: changeset,
page_title: "Edit Product"
)
else
conn
|> put_flash(:error, "Product not found.")
|> redirect(to: ~p"/pripyat/subscriptions")
end
end
def update(conn, %{"id" => id, "product" => product_params}) do
product = Subscriptions.get_product(id)
if product do
# Parse features from textarea (one per line)
product_params = parse_features(product_params)
case Subscriptions.update_product(product, product_params) do
{:ok, _product} ->
conn
|> put_flash(:info, "Product updated successfully.")
|> redirect(to: ~p"/pripyat/subscriptions")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit_product,
product: product,
changeset: changeset,
page_title: "Edit Product"
)
end
else
conn
|> put_flash(:error, "Product not found.")
|> redirect(to: ~p"/pripyat/subscriptions")
end
end
def delete(conn, %{"id" => id}) do
product = Subscriptions.get_product(id)
if product do
case Subscriptions.delete_product(product) do
{:ok, _} ->
conn
|> put_flash(:info, "Product deleted successfully.")
|> redirect(to: ~p"/pripyat/subscriptions")
{:error, :has_subscriptions} ->
conn
|> put_flash(
:error,
"This product already has subscription history. Deactivate it instead of deleting it."
)
|> redirect(to: ~p"/pripyat/subscriptions")
{:error, _} ->
conn
|> put_flash(:error, "Unable to delete product.")
|> redirect(to: ~p"/pripyat/subscriptions")
end
else
conn
|> put_flash(:error, "Product not found.")
|> redirect(to: ~p"/pripyat/subscriptions")
end
end
def toggle(conn, %{"id" => id}) do
product = Subscriptions.get_product(id)
if product do
case Subscriptions.update_product(product, %{active: !product.active}) do
{:ok, updated} ->
status = if updated.active, do: "activated", else: "deactivated"
conn
|> put_flash(:info, "Product #{status}.")
|> redirect(to: ~p"/pripyat/subscriptions")
{:error, _} ->
conn
|> put_flash(:error, "Unable to toggle product status.")
|> redirect(to: ~p"/pripyat/subscriptions")
end
else
conn
|> put_flash(:error, "Product not found.")
|> redirect(to: ~p"/pripyat/subscriptions")
end
end
# Parse features from textarea (one feature per line)
defp parse_features(%{"features" => features} = params) when is_binary(features) do
parsed =
features
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
Map.put(params, "features", parsed)
end
defp parse_features(params), do: params
end

View file

@ -1,12 +0,0 @@
defmodule ElektrineWeb.Admin.SubscriptionsHTML do
@moduledoc """
View helpers and templates for admin subscription products.
"""
use ElektrineWeb, :html
# Delegate template rendering to AdminHTML since templates are in admin_html directory
defdelegate subscriptions(assigns), to: ElektrineWeb.AdminHTML
defdelegate new_product(assigns), to: ElektrineWeb.AdminHTML
defdelegate edit_product(assigns), to: ElektrineWeb.AdminHTML
end

View file

@ -6,7 +6,7 @@ defmodule ElektrineWeb.AdminController do
use ElektrineWeb, :controller
alias Elektrine.{Accounts, AppCache, Repo, Subscriptions}
alias Elektrine.{Accounts, AppCache, Repo}
alias ElektrineWeb.Platform.Integrations
import Ecto.Query
@ -78,8 +78,7 @@ defmodule ElektrineWeb.AdminController do
{:federation, fn -> get_federation_stats() end, default_federation_stats()},
{:arblarg_federation, fn -> get_arblarg_federation_stats() end,
default_arblarg_federation_stats()},
{:bluesky_bridge, fn -> get_bluesky_bridge_stats() end, default_bluesky_bridge_stats()},
{:subscriptions, fn -> get_subscription_stats() end, default_subscription_stats()}
{:bluesky_bridge, fn -> get_bluesky_bridge_stats() end, default_bluesky_bridge_stats()}
]
|> Task.async_stream(
fn {key, fetch_fn, fallback} ->
@ -117,8 +116,7 @@ defmodule ElektrineWeb.AdminController do
federation: Map.get(async_stats, :federation, default_federation_stats()),
arblarg_federation:
Map.get(async_stats, :arblarg_federation, default_arblarg_federation_stats()),
bluesky_bridge: Map.get(async_stats, :bluesky_bridge, default_bluesky_bridge_stats()),
subscriptions: Map.get(async_stats, :subscriptions, default_subscription_stats())
bluesky_bridge: Map.get(async_stats, :bluesky_bridge, default_bluesky_bridge_stats())
}
end
@ -358,29 +356,6 @@ defmodule ElektrineWeb.AdminController do
}
end
defp get_subscription_stats do
alias Subscriptions.Subscription
products = Subscriptions.list_products()
active_products = Enum.count(products, & &1.active)
active_subscriptions =
Repo.aggregate(
from(s in Subscription, where: s.status in ["active", "trialing"]),
:count,
:id
)
total_subscriptions = Repo.aggregate(Subscription, :count, :id)
%{
total_products: length(products),
active_products: active_products,
active_subscriptions: active_subscriptions,
total_subscriptions: total_subscriptions
}
end
defp safe_dashboard_call(fetch_fn, fallback) when is_function(fetch_fn, 0) do
fetch_fn.()
rescue
@ -442,14 +417,6 @@ defmodule ElektrineWeb.AdminController do
}
end
defp default_subscription_stats do
%{
total_products: 0,
active_products: 0,
active_subscriptions: 0,
total_subscriptions: 0
}
end
defp default_custom_domain_stats do
%{

View file

@ -335,11 +335,6 @@ defmodule ElektrineWeb.AdminHTML do
path: "/pripyat/announcements",
icon: "hero-megaphone"
},
%{
label: "Subscriptions",
path: "/pripyat/subscriptions",
icon: "hero-credit-card"
},
%{
label: "ActivityPub Policies",
path: "/pripyat/federation",

View file

@ -660,20 +660,6 @@
</.link>
</div>
<div class="rounded-box border border-base-content/10 bg-base-200/35 p-4">
<div class="text-sm text-base-content/60">Active Subscriptions</div>
<div class="mt-2 text-3xl font-semibold text-info">
{@stats.subscriptions.active_subscriptions}
</div>
<.link
href={~p"/pripyat/subscriptions"}
class="mt-3 inline-flex text-sm font-medium text-info"
>
{@stats.subscriptions.active_products} active products →
</.link>
</div>
</div>
</div>

View file

@ -1,244 +0,0 @@
<div class="admin-page">
<.card class="panel-card" body_class="p-0">
<:body>
<div class="px-5 py-6 sm:px-8 sm:py-8">
<.link
href={~p"/pripyat/subscriptions"}
class="inline-flex items-center gap-1.5 text-sm text-base-content/60 transition-colors hover:text-base-content"
>
<.icon name="hero-chevron-left" class="h-4 w-4" /> Back to products
</.link>
<div class="mt-5 text-2xs font-semibold uppercase tracking-[0.32em] text-info/80">
Billing
</div>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">Edit Product: {@product.name}</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-base-content/70">
Update product details and Stripe pricing.
</p>
</div>
</:body>
</.card>
<.card class="panel-card">
<:body>
<.simple_form
:let={f}
for={@changeset}
action={~p"/pripyat/subscriptions/#{@product.id}"}
method="put"
>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<% features_value =
Phoenix.HTML.Form.input_value(f, :features) || @product.features || [] %>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<.input field={f[:name]} type="text" label="Name" placeholder="e.g., VPN" required />
</div>
<div>
<label class="label"><span class="label-text font-medium">Slug</span></label>
<div class="input input-bordered flex items-center bg-base-200 font-mono text-sm">
{@product.slug}
</div>
<p class="mt-1.5 text-xs text-base-content/55">
Product slugs are immutable after creation so existing subscriptions and webhook mappings stay stable.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<.input
field={f[:billing_type]}
type="select"
label="Billing Type"
options={[{"Recurring subscription", "recurring"}, {"One-time payment", "one_time"}]}
/>
<p class="mt-1.5 text-xs text-base-content/55">
One-time products grant access after a single paid checkout and do not renew.
</p>
</div>
<div><.input field={f[:sort_order]} type="number" label="Sort Order" /></div>
</div>
<div>
<.input
field={f[:description]}
type="textarea"
label="Description"
placeholder="Brief description of the product..."
rows="3"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Features</span>
<span class="label-text-alt">One per line</span>
</label>
<textarea
name="product[features]"
placeholder="Feature 1&#10;Feature 2&#10;Feature 3"
class="textarea textarea-bordered h-32 w-full font-mono text-sm"
>{Enum.join(features_value, "\n")}</textarea>
</div>
<div class="border-t border-base-content/10 pt-5">
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
Pricing
</div>
</div>
<div class="flex items-start gap-3 rounded-box border border-info/20 bg-info/10 px-4 py-3 text-sm text-base-content/75">
<.icon name="hero-information-circle" class="mt-0.5 h-5 w-5 shrink-0 text-info" />
<span>
Stripe price IDs sync the amount and currency each time you save. Use either recurring prices or a one-time price, depending on the billing type.
</span>
</div>
<div class="rounded-box border border-base-content/10 bg-base-200/35 p-4">
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
Recurring Pricing
</div>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<.input
field={f[:monthly_price_cents]}
type="number"
label="Monthly Price (cents)"
placeholder="1900"
min="0"
/>
<p class="mt-1.5 text-xs text-base-content/55">e.g., 1900 = $19.00</p>
</div>
<div>
<.input
field={f[:yearly_price_cents]}
type="number"
label="Yearly Price (cents)"
placeholder="19000"
min="0"
/>
<p class="mt-1.5 text-xs text-base-content/55">e.g., 19000 = $190.00</p>
</div>
<div>
<.input
field={f[:currency]}
type="select"
label="Currency"
options={[{"USD ($)", "usd"}, {"EUR", "eur"}, {"GBP", "gbp"}]}
/>
</div>
</div>
</div>
<div class="rounded-box border border-base-content/10 bg-base-200/35 p-4">
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
One-time Pricing
</div>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<.input
field={f[:one_time_price_cents]}
type="number"
label="One-time Price (cents)"
placeholder="500"
min="0"
/>
<p class="mt-1.5 text-xs text-base-content/55">e.g., 500 = $5.00</p>
</div>
<div class="flex items-end">
<div class="text-sm text-base-content/60">
Use this for pay-once access such as registration fees or lifetime unlocks.
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<.input
field={f[:stripe_monthly_price_id]}
type="text"
label="Stripe Monthly Price ID"
placeholder="price_xxx"
/>
<p class="mt-1.5 text-xs text-base-content/55">
Must be a recurring monthly Stripe price.
</p>
</div>
<div>
<.input
field={f[:stripe_yearly_price_id]}
type="text"
label="Stripe Yearly Price ID"
placeholder="price_xxx"
/>
<p class="mt-1.5 text-xs text-base-content/55">
Must be a recurring yearly price.
</p>
</div>
<div>
<.input
field={f[:stripe_one_time_price_id]}
type="text"
label="Stripe One-time Price ID"
placeholder="price_xxx"
/>
<p class="mt-1.5 text-xs text-base-content/55">
Must be a non-recurring Stripe price.
</p>
</div>
</div>
<div class="border-t border-base-content/10 pt-5">
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
Settings
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="label cursor-pointer justify-start gap-3 rounded-box border border-base-content/10 bg-base-200/35 px-4 py-3">
<input
type="checkbox"
name="product[active]"
value="true"
checked={
Phoenix.HTML.Form.normalize_value(
"checkbox",
Phoenix.HTML.Form.input_value(f, :active)
)
}
class="checkbox checkbox-primary"
/> <span>Active (visible to users)</span>
</label>
</div>
</div>
<:actions>
<div class="flex gap-2">
<.button>
<.icon name="hero-check" class="h-4 w-4" /> Update Product
</.button>
<.button href={~p"/pripyat/subscriptions"} variant="ghost">Cancel</.button>
</div>
</:actions>
</.simple_form>
</:body>
</.card>
</div>

View file

@ -1,241 +0,0 @@
<div class="admin-page">
<.card class="panel-card" body_class="p-0">
<:body>
<div class="px-5 py-6 sm:px-8 sm:py-8">
<.link
href={~p"/pripyat/subscriptions"}
class="inline-flex items-center gap-1.5 text-sm text-base-content/60 transition-colors hover:text-base-content"
>
<.icon name="hero-chevron-left" class="h-4 w-4" /> Back to products
</.link>
<div class="mt-5 text-2xs font-semibold uppercase tracking-[0.32em] text-info/80">
Billing
</div>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">New Subscription Product</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-base-content/70">
Create a new product that users can subscribe to.
</p>
</div>
</:body>
</.card>
<.card class="panel-card">
<:body>
<.simple_form :let={f} for={@changeset} action={~p"/pripyat/subscriptions"}>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<% features_value = Phoenix.HTML.Form.input_value(f, :features) || [] %>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<.input field={f[:name]} type="text" label="Name" placeholder="e.g., VPN" required />
</div>
<div>
<.input
field={f[:slug]}
type="text"
label="Slug"
placeholder="e.g., vpn"
pattern="[a-z0-9-]+"
required
/>
<p class="mt-1.5 text-xs text-base-content/55">
Lowercase letters, numbers, and hyphens only. Used in URLs.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<.input
field={f[:billing_type]}
type="select"
label="Billing Type"
options={[{"Recurring subscription", "recurring"}, {"One-time payment", "one_time"}]}
/>
<p class="mt-1.5 text-xs text-base-content/55">
One-time products grant access after a single paid checkout and do not renew.
</p>
</div>
<div><.input field={f[:sort_order]} type="number" label="Sort Order" value="0" /></div>
</div>
<div>
<.input
field={f[:description]}
type="textarea"
label="Description"
placeholder="Brief description of the product..."
rows="3"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Features</span>
<span class="label-text-alt">One per line</span>
</label>
<textarea
name="product[features]"
placeholder="Feature 1&#10;Feature 2&#10;Feature 3"
class="textarea textarea-bordered h-32 w-full font-mono text-sm"
>{Enum.join(features_value, "\n")}</textarea>
</div>
<div class="border-t border-base-content/10 pt-5">
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
Pricing
</div>
</div>
<div class="flex items-start gap-3 rounded-box border border-info/20 bg-info/10 px-4 py-3 text-sm text-base-content/75">
<.icon name="hero-information-circle" class="mt-0.5 h-5 w-5 shrink-0 text-info" />
<span>
Stripe price IDs sync the amount and currency when you save. Use either recurring prices or a one-time price, depending on the billing type.
</span>
</div>
<div class="rounded-box border border-base-content/10 bg-base-200/35 p-4">
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
Recurring Pricing
</div>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<.input
field={f[:monthly_price_cents]}
type="number"
label="Monthly Price (cents)"
placeholder="1900"
min="0"
/>
<p class="mt-1.5 text-xs text-base-content/55">e.g., 1900 = $19.00</p>
</div>
<div>
<.input
field={f[:yearly_price_cents]}
type="number"
label="Yearly Price (cents)"
placeholder="19000"
min="0"
/>
<p class="mt-1.5 text-xs text-base-content/55">e.g., 19000 = $190.00</p>
</div>
<div>
<.input
field={f[:currency]}
type="select"
label="Currency"
options={[{"USD ($)", "usd"}, {"EUR", "eur"}, {"GBP", "gbp"}]}
/>
</div>
</div>
</div>
<div class="rounded-box border border-base-content/10 bg-base-200/35 p-4">
<div class="text-2xs font-semibold uppercase tracking-[0.18em] text-base-content/45">
One-time Pricing
</div>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<.input
field={f[:one_time_price_cents]}
type="number"
label="One-time Price (cents)"
placeholder="500"
min="0"
/>
<p class="mt-1.5 text-xs text-base-content/55">e.g., 500 = $5.00</p>
</div>
<div class="flex items-end">
<div class="text-sm text-base-content/60">
Use this for pay-once access such as registration fees or lifetime unlocks.
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<.input
field={f[:stripe_monthly_price_id]}
type="text"
label="Stripe Monthly Price ID"
placeholder="price_xxx"
/>
<p class="mt-1.5 text-xs text-base-content/55">
Must be a recurring monthly Stripe price.
</p>
</div>
<div>
<.input
field={f[:stripe_yearly_price_id]}
type="text"
label="Stripe Yearly Price ID"
placeholder="price_xxx"
/>
<p class="mt-1.5 text-xs text-base-content/55">
Must be a recurring yearly Stripe price.
</p>
</div>
<div>
<.input
field={f[:stripe_one_time_price_id]}
type="text"
label="Stripe One-time Price ID"
placeholder="price_xxx"
/>
<p class="mt-1.5 text-xs text-base-content/55">
Must be a non-recurring Stripe price.
</p>
</div>
</div>
<div class="border-t border-base-content/10 pt-5">
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
Settings
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="label cursor-pointer justify-start gap-3 rounded-box border border-base-content/10 bg-base-200/35 px-4 py-3">
<input
type="checkbox"
name="product[active]"
value="true"
checked={
Phoenix.HTML.Form.normalize_value(
"checkbox",
Phoenix.HTML.Form.input_value(f, :active)
)
}
class="checkbox checkbox-primary"
/> <span>Active (visible to users)</span>
</label>
</div>
</div>
<:actions>
<div class="flex gap-2">
<.button>
<.icon name="hero-plus" class="h-4 w-4" /> Create Product
</.button>
<.button href={~p"/pripyat/subscriptions"} variant="ghost">Cancel</.button>
</div>
</:actions>
</.simple_form>
</:body>
</.card>
</div>

View file

@ -1,310 +0,0 @@
<div class="admin-page">
<.card class="panel-card" body_class="p-0">
<:body>
<div class="flex flex-col gap-6 px-5 py-6 sm:px-8 sm:py-8 lg:flex-row lg:items-end lg:justify-between">
<div class="max-w-2xl">
<div class="text-2xs font-semibold uppercase tracking-[0.32em] text-info/80">
Billing
</div>
<h1 class="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">
Subscription Products
</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-base-content/70 sm:text-base">
Manage subscription products and Stripe pricing.
</p>
<div class="mt-5 flex flex-wrap gap-2">
<div class="surface-muted rounded-box px-3 py-2 text-sm text-base-content/70">
Total products:
<span class="font-semibold text-base-content">{length(@products)}</span>
</div>
<div class="surface-muted rounded-box px-3 py-2 text-sm text-base-content/70">
Active:
<span class="font-semibold text-base-content">
{Enum.count(@products, & &1.active)}
</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<.button href={~p"/pripyat/subscriptions/new"}>
<.icon name="hero-plus" class="h-4 w-4" /> New Product
</.button>
</div>
</div>
</:body>
</.card>
<.card class="panel-card" body_class="p-0">
<:body>
<div class="border-b border-base-content/10 px-5 py-5 sm:px-6">
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
Catalog
</div>
<h2 class="mt-1 text-xl font-semibold tracking-tight">Products</h2>
</div>
<div class="px-5 py-5 sm:px-6">
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Product</th>
<th>Slug</th>
<th>Type</th>
<th>Monthly</th>
<th>Yearly</th>
<th>One-time</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%= for product <- @products do %>
<tr>
<td>
<div class="flex min-w-0 flex-col">
<span class="font-semibold">{product.name}</span>
<%= if product.description do %>
<span class="max-w-xs truncate text-xs text-base-content/55">
{product.description}
</span>
<% end %>
</div>
</td>
<td>
<code class="rounded-lg bg-base-200 px-2 py-1 text-xs">{product.slug}</code>
</td>
<td>
<span class={[
"badge badge-sm",
if(Elektrine.Subscriptions.Product.one_time?(product),
do: "badge-primary",
else: "badge-secondary"
)
]}>
{if Elektrine.Subscriptions.Product.one_time?(product),
do: "One-time",
else: "Recurring"}
</span>
</td>
<td>
<%= if Elektrine.Subscriptions.Product.recurring?(product) and product.monthly_price_cents do %>
<span class="font-medium">
{Elektrine.Subscriptions.Product.format_price(
product.monthly_price_cents,
product.currency
)}
</span>
<%= if product.stripe_monthly_price_id do %>
<.icon
name="hero-check-circle"
class="ml-1 h-4 w-4 text-success"
title="Stripe configured"
/>
<% else %>
<.icon
name="hero-exclamation-triangle"
class="ml-1 h-4 w-4 text-warning"
title="No Stripe price ID"
/>
<% end %>
<% else %>
<span class="text-base-content/40">-</span>
<% end %>
</td>
<td>
<%= if Elektrine.Subscriptions.Product.recurring?(product) and product.yearly_price_cents do %>
<span class="font-medium">
{Elektrine.Subscriptions.Product.format_price(
product.yearly_price_cents,
product.currency
)}
</span>
<%= if product.stripe_yearly_price_id do %>
<.icon
name="hero-check-circle"
class="ml-1 h-4 w-4 text-success"
title="Stripe configured"
/>
<% else %>
<.icon
name="hero-exclamation-triangle"
class="ml-1 h-4 w-4 text-warning"
title="No Stripe price ID"
/>
<% end %>
<% else %>
<span class="text-base-content/40">-</span>
<% end %>
</td>
<td>
<%= if Elektrine.Subscriptions.Product.one_time?(product) and product.one_time_price_cents do %>
<span class="font-medium">
{Elektrine.Subscriptions.Product.format_price(
product.one_time_price_cents,
product.currency
)}
</span>
<%= if product.stripe_one_time_price_id do %>
<.icon
name="hero-check-circle"
class="ml-1 h-4 w-4 text-success"
title="Stripe configured"
/>
<% else %>
<.icon
name="hero-exclamation-triangle"
class="ml-1 h-4 w-4 text-warning"
title="No Stripe price ID"
/>
<% end %>
<% else %>
<span class="text-base-content/40">-</span>
<% end %>
</td>
<td>
<%= if product.active do %>
<span class="badge badge-success">Active</span>
<% else %>
<span class="badge badge-ghost">Inactive</span>
<% end %>
</td>
<td>
<div class="flex gap-1">
<.button
href={~p"/pripyat/subscriptions/#{product.id}/edit"}
variant="ghost"
size="xs"
title="Edit"
>
<.icon name="hero-pencil" class="h-4 w-4" />
</.button>
<.form
for={%{}}
action={~p"/pripyat/subscriptions/#{product.id}/toggle"}
method="post"
class="inline"
>
<.button
type="submit"
variant="ghost"
size="xs"
title={if product.active, do: "Deactivate", else: "Activate"}
>
<%= if product.active do %>
<.icon name="hero-pause" class="h-4 w-4 text-warning" />
<% else %>
<.icon name="hero-play" class="h-4 w-4 text-success" />
<% end %>
</.button>
</.form>
<.form
for={%{}}
action={~p"/pripyat/subscriptions/#{product.id}"}
method="delete"
class="inline"
>
<.button
type="submit"
variant="ghost"
size="xs"
class="text-error"
data-confirm="Delete this product? This cannot be undone."
title="Delete"
>
<.icon name="hero-trash" class="h-4 w-4" />
</.button>
</.form>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @products == [] do %>
<div class="rounded-box border border-dashed border-base-content/15 bg-base-200/45 px-4 py-10 text-center">
<.icon name="hero-cube" class="mx-auto h-12 w-12 text-base-content/30" />
<p class="mt-4 text-lg font-medium text-base-content/70">No subscription products</p>
<p class="mt-2 text-sm text-base-content/55">
Create your first product to enable subscriptions
</p>
<div class="mt-6">
<.button href={~p"/pripyat/subscriptions/new"} size="sm">
<.icon name="hero-plus" class="h-4 w-4" /> Create Product
</.button>
</div>
</div>
<% end %>
</div>
</:body>
</.card>
<.card class="panel-card" body_class="p-0">
<:body>
<div class="border-b border-base-content/10 px-5 py-5 sm:px-6">
<div class="text-2xs font-semibold uppercase tracking-[0.22em] text-base-content/45">
Reference
</div>
<h2 class="mt-1 text-xl font-semibold tracking-tight">Stripe Setup Instructions</h2>
</div>
<div class="px-5 py-5 sm:px-6">
<ol class="list-decimal list-inside space-y-2 text-sm text-base-content/75">
<li>
Create a product in your
<a
href="https://dashboard.stripe.com/products"
target="_blank"
class="link link-primary"
>
Stripe Dashboard
</a>
</li>
<li>Choose whether the product should be recurring or one-time</li>
<li>Add the matching Stripe price for that billing type</li>
<li>
Copy the price IDs (e.g., <code class="rounded-lg bg-base-200 px-1">price_xxx</code>) from Stripe
</li>
<li>Paste them into the product settings here</li>
<li>
Set up the webhook endpoint:
<code class="rounded-lg bg-base-200 px-1">/webhook/stripe</code>
</li>
</ol>
</div>
</:body>
</.card>
</div>

View file

@ -876,7 +876,6 @@ defmodule ElektrineWeb.LiteController do
end
end
defp captcha_error_message(:captcha_expired), do: "Captcha expired."
defp captcha_error_message(:wrong_answer), do: "Wrong captcha."
defp captcha_error_message(:missing_captcha), do: "Enter the captcha."
defp captcha_error_message(:invalid_token), do: "Captcha expired. Refresh."

View file

@ -1,57 +0,0 @@
defmodule ElektrineWeb.RegistrationPaymentController do
use ElektrineWeb, :controller
alias Elektrine.Subscriptions
alias Elektrine.Subscriptions.Product
def create(conn, _params) do
with %Product{} = product <- Subscriptions.get_active_registration_product(),
true <- Product.has_one_time?(product),
{:ok, %{url: checkout_url}} <-
Subscriptions.create_registration_checkout_session(product) do
redirect(conn, external: checkout_url)
else
_ ->
conn
|> put_flash(:error, "Registration payment is not available right now.")
|> redirect(to: ~p"/register")
end
end
def show(conn, %{"checkout_session_id" => session_id, "access" => access}) do
case Subscriptions.get_registration_checkout(session_id, access) do
nil ->
render_checkout(conn, nil)
_checkout ->
conn
|> put_session(:registration_access_token, access)
|> redirect(to: ~p"/register/purchase/success?checkout_session_id=#{session_id}")
end
end
def show(conn, %{"checkout_session_id" => session_id}) do
checkout =
case get_session(conn, :registration_access_token) do
token when is_binary(token) -> Subscriptions.get_registration_checkout(session_id, token)
_ -> nil
end
render_checkout(conn, checkout)
end
def show(conn, _params) do
conn
|> put_flash(:error, "Missing registration payment details.")
|> redirect(to: ~p"/register")
end
defp render_checkout(conn, checkout) do
render(conn, :show,
page_title: "Registration Payment",
checkout: checkout,
invite_code: checkout && checkout.invite_code,
pending: is_nil(checkout) or checkout.status != "fulfilled"
)
end
end

View file

@ -1,9 +0,0 @@
defmodule ElektrineWeb.RegistrationPaymentHTML do
@moduledoc """
Templates for paid registration checkout.
"""
use ElektrineWeb, :html
embed_templates "registration_payment_html/*"
end

View file

@ -1,60 +0,0 @@
<div class="mx-auto max-w-lg px-4 py-12">
<.card>
<:body>
<%= if @pending do %>
<h1 class="text-2xl font-bold mb-2">Payment Received</h1>
<p class="opacity-70 mb-6">
Your payment is still being matched to an invite code. Refresh this page in a moment.
</p>
<div class="flex flex-col sm:flex-row gap-3">
<.button type="button" class="flex-1" data-action="reload-page">
Refresh
</.button>
<.button href={~p"/register"} variant="ghost" class="flex-1">Back to Register</.button>
</div>
<% else %>
<%= if @invite_code do %>
<h1 class="text-2xl font-bold mb-2">Invite Ready</h1>
<p class="opacity-70 mb-4">
Use this single-use invite code to finish creating your account.
</p>
<div class="rounded-box border border-base-300 bg-base-200 p-4 mb-6">
<div class="text-xs uppercase tracking-wide opacity-60 mb-2">Invite Code</div>
<div class="font-mono text-2xl font-semibold">{@invite_code.code}</div>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<.button href={~p"/register?invite_code=#{@invite_code.code}"} class="flex-1">
Continue to Register
</.button>
<.button
type="button"
variant="ghost"
class="flex-1"
data-copy-to-clipboard={@invite_code.code}
>
Copy Code
</.button>
</div>
<% else %>
<h1 class="text-2xl font-bold mb-2">Access Ready</h1>
<p class="opacity-70 mb-6">
Your one-time fee has been recorded. Continue to registration and create your account.
</p>
<div class="flex flex-col sm:flex-row gap-3">
<.button href={~p"/register?access=#{@checkout.lookup_token}"} class="flex-1">
Continue to Register
</.button>
</div>
<% end %>
<% end %>
</:body>
</.card>
</div>

View file

@ -1,91 +0,0 @@
defmodule ElektrineWeb.StripeWebhookController do
@moduledoc """
Handles Stripe webhook events for subscription management.
"""
use ElektrineWeb, :controller
require Logger
alias Elektrine.Subscriptions
@doc """
Process incoming Stripe webhook events.
Verifies the webhook signature and processes subscription-related events.
"""
def webhook(conn, _params) do
raw_body = conn.assigns[:raw_body] || conn.private[:cached_body]
signature = get_stripe_signature(conn)
signing_secret = get_signing_secret()
with {:ok, _} <- validate_raw_body(raw_body),
{:ok, _} <- validate_signature(signature),
{:ok, _} <- validate_signing_secret(signing_secret),
{:ok, event} <- construct_event(raw_body, signature, signing_secret),
{:ok, _result} <- Subscriptions.process_webhook_event(event) do
json(conn, %{received: true})
else
{:error, :no_raw_body} ->
Logger.warning("Stripe webhook: missing raw body")
conn
|> put_status(:bad_request)
|> json(%{error: "Missing request body"})
{:error, :no_signature} ->
Logger.warning("Stripe webhook: missing signature")
conn
|> put_status(:bad_request)
|> json(%{error: "Missing Stripe-Signature header"})
{:error, :no_signing_secret} ->
Logger.error("Stripe webhook: signing secret not configured")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Webhook not configured"})
{:error, %Stripe.Error{message: message}} ->
Logger.warning("Stripe webhook signature verification failed: #{message}")
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid signature"})
{:error, reason} ->
Logger.error("Stripe webhook error: #{inspect(reason)}")
conn
|> put_status(:bad_request)
|> json(%{error: "Webhook processing failed"})
end
end
defp get_stripe_signature(conn) do
case Plug.Conn.get_req_header(conn, "stripe-signature") do
[signature | _] -> signature
[] -> nil
end
end
defp get_signing_secret do
Application.get_env(:stripity_stripe, :signing_secret)
end
defp validate_raw_body(nil), do: {:error, :no_raw_body}
defp validate_raw_body(""), do: {:error, :no_raw_body}
defp validate_raw_body(body) when is_binary(body), do: {:ok, body}
defp validate_signature(nil), do: {:error, :no_signature}
defp validate_signature(""), do: {:error, :no_signature}
defp validate_signature(sig) when is_binary(sig), do: {:ok, sig}
defp validate_signing_secret(nil), do: {:error, :no_signing_secret}
defp validate_signing_secret(""), do: {:error, :no_signing_secret}
defp validate_signing_secret(secret) when is_binary(secret), do: {:ok, secret}
defp construct_event(raw_body, signature, signing_secret) do
Stripe.Webhook.construct_event(raw_body, signature, signing_secret)
end
end

View file

@ -6,7 +6,6 @@ defmodule ElektrineWeb.Endpoint do
@cache_raw_body_plug :"Elixir.ElektrineWeb.Plugs.CacheRawBody"
@cache_raw_body_opts %{
paths: [
"/webhook/stripe",
"/api/ext/v1/static-site/deploy/github/webhook",
"/_arblarg/events",
"/_arblarg/events/batch",

View file

@ -157,50 +157,6 @@ defmodule ElektrineWeb.Live.AuthHooks do
end
end
@doc """
Requires user to have an active subscription for a product.
Use with: on_mount {ElektrineWeb.Live.AuthHooks, {:require_subscription, "vpn"}}
"""
def on_mount({:require_subscription, product}, _params, session, socket) do
socket = mount_current_user(socket, session)
case socket.assigns[:current_user] do
nil ->
socket =
socket
|> notify_error("You must log in to access this page.")
|> redirect(to: Elektrine.Paths.login_path())
{:halt, socket}
%{banned: true} = user ->
message =
if Elektrine.Strings.present?(user.banned_reason) do
"Your account has been banned. Reason: #{user.banned_reason}"
else
"Your account has been banned."
end
socket =
socket
|> notify_error(message)
|> redirect(to: ~p"/logout")
{:halt, socket}
user ->
if Elektrine.Subscriptions.has_access?(user, product) do
{:cont, socket}
else
socket =
socket
|> redirect(to: ~p"/subscribe/#{product}")
{:halt, socket}
end
end
end
defp enforce_authenticated_user(socket, unauthenticated_message) do
case socket.assigns[:current_user] do
%{banned: true} = user ->

View file

@ -4,8 +4,6 @@ defmodule ElektrineWeb.AuthLive.Register do
import Ecto.Changeset, only: [add_error: 3, cast: 3]
alias Elektrine.Accounts.User
alias Elektrine.Subscriptions
alias Elektrine.Subscriptions.Product
alias ElektrineWeb.AtominePow
def mount(params, session, socket) do
@ -15,10 +13,6 @@ defmodule ElektrineWeb.AuthLive.Register do
changeset = registration_changeset(session, params)
invite_codes_enabled = Elektrine.System.invite_codes_enabled?()
via_tor = via_tor_request?(socket, session)
registration_access = registration_access(session, params)
registration_payment_product =
if invite_codes_enabled, do: Subscriptions.get_active_registration_product(), else: nil
atomine_pow_enabled = AtominePow.enabled?()
atomine_pow_difficulty = AtominePow.difficulty()
@ -37,9 +31,9 @@ defmodule ElektrineWeb.AuthLive.Register do
changeset: changeset,
invite_codes_enabled: invite_codes_enabled,
via_tor: via_tor,
registration_access: registration_access,
registration_payment_product: registration_payment_product,
registration_payment_price: format_registration_price(registration_payment_product),
registration_access: nil,
registration_payment_product: nil,
registration_payment_price: nil,
atomine_pow_enabled: atomine_pow_enabled,
atomine_pow_difficulty: atomine_pow_difficulty
)
@ -50,7 +44,6 @@ defmodule ElektrineWeb.AuthLive.Register do
defp registration_changeset(session, params) do
form_data = Map.get(session, "registration_form", %{})
form_data = maybe_put_prefilled_invite_code(form_data, params)
form_data = maybe_put_prefilled_registration_access(form_data, session, params)
validation_errors = Map.get(session, "registration_errors", %{})
changeset =
@ -60,7 +53,6 @@ defmodule ElektrineWeb.AuthLive.Register do
:password,
:password_confirmation,
:invite_code,
:registration_access_token,
:registration_ip,
:registered_via_onion
])
@ -121,58 +113,10 @@ defmodule ElektrineWeb.AuthLive.Register do
defp maybe_put_prefilled_invite_code(form_data, _params), do: form_data
defp maybe_put_prefilled_registration_access(form_data, session, params) do
access_token = registration_access_param(params) || session["registration_access_token"]
cond do
!is_binary(access_token) -> form_data
not Elektrine.Strings.present?(access_token) -> form_data
Map.get(form_data, "registration_access_token") -> form_data
true -> Map.put(form_data, "registration_access_token", String.trim(access_token))
end
end
defp registration_access(session, params) do
access_token = registration_access_param(params) || session["registration_access_token"]
case access_token do
token when is_binary(token) ->
lookup_token = String.trim(token)
if lookup_token == "" do
nil
else
case Subscriptions.get_registration_checkout_by_token(lookup_token) do
%{status: "fulfilled", redeemed_at: nil} = checkout ->
%{status: :ready, token: lookup_token, checkout: checkout}
%{status: "fulfilled"} ->
%{status: :used, token: lookup_token}
%{} ->
%{status: :pending, token: lookup_token}
nil ->
nil
end
end
_ ->
nil
end
end
defp registration_access_param(%{} = params), do: Map.get(params, "access")
defp registration_access_param(_), do: nil
defp normalize_mount_map(%{} = value), do: value
defp normalize_mount_map(_value), do: %{}
defp format_registration_price(%Product{} = product) do
Product.format_price(product.one_time_price_cents, product.currency)
end
defp format_registration_price(_), do: nil
def render(assigns) do
~H"""
@ -180,21 +124,7 @@ defmodule ElektrineWeb.AuthLive.Register do
<:body>
<h1 class="text-center text-3xl font-bold mb-6">{gettext("Register")}</h1>
<%= if @registration_access && @registration_access.status == :pending do %>
<div class="alert alert-info mb-4">
<.icon name="hero-arrow-path" class="w-5 h-5" />
<span>
{gettext("Your payment is still being confirmed. Refresh this page in a moment.")}
</span>
</div>
<% end %>
<%= if @registration_access && @registration_access.status == :used do %>
<div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>{gettext("This paid registration link has already been used.")}</span>
</div>
<% end %>
<.simple_form
:let={f}
@ -249,29 +179,6 @@ defmodule ElektrineWeb.AuthLive.Register do
/>
<%= if @invite_codes_enabled do %>
<%= if @registration_access && @registration_access.status == :ready do %>
<input
type="hidden"
name="user[registration_access_token]"
value={@registration_access.token}
/>
<div class="alert alert-success mb-4">
<.icon name="hero-check-circle" class="w-5 h-5" />
<span>
{gettext(
"Paid access verified. You can create your account without an invite code."
)}
</span>
</div>
<% else %>
<.input
field={f[:invite_code]}
type="text"
label={gettext("Invite Code")}
placeholder={gettext("Enter your invite code")}
/>
<% end %>
<% end %>
<div class="form-control my-4">
@ -392,29 +299,6 @@ defmodule ElektrineWeb.AuthLive.Register do
</:actions>
</.simple_form>
<%= if @invite_codes_enabled && @registration_payment_product do %>
<div class="rounded-box border border-base-300 bg-base-200/50 p-4 mt-4">
<div class="flex items-start justify-between gap-4">
<div>
<div class="font-medium">{gettext("No invite?")}</div>
<div class="text-sm opacity-70">
{gettext("Pay once to unlock registration without an invite code.")}
</div>
</div>
<%= if @registration_payment_price do %>
<div class="text-sm font-semibold whitespace-nowrap">
{@registration_payment_price}
</div>
<% end %>
</div>
<.form for={%{}} action={~p"/register/purchase"} method="post" class="mt-3">
<.button type="submit" variant="default" outline size="sm" class="w-full">
{gettext("Pay One-Time Fee")}
</.button>
</.form>
</div>
<% end %>
<%= if Elektrine.Payments.Crypto.monero_enabled?() do %>
<div class="rounded-box border border-base-300 bg-base-200/50 p-4 mt-4 space-y-2">

View file

@ -1,552 +0,0 @@
defmodule ElektrineWeb.SubscribeLive do
@moduledoc """
Universal subscription page for products.
Handles checkout and subscription management via Stripe.
Products and prices are managed via admin panel.
"""
use ElektrineWeb, :live_view
alias Elektrine.Subscriptions
alias Elektrine.Subscriptions.{Product, Subscription}
import ElektrineWeb.Components.Platform.ElektrineNav
@impl true
def mount(%{"product" => product_slug}, _session, socket) do
user = socket.assigns[:current_user]
product = Subscriptions.get_active_product_by_slug(product_slug)
if product == nil do
{:ok, push_navigate(socket, to: ~p"/")}
else
subscription = if user, do: Subscriptions.get_subscription(user.id, product_slug), else: nil
socket =
socket
|> assign(:page_title, "Subscribe to #{product.name}")
|> assign(:product, product)
|> assign(:subscription, subscription)
|> assign(:pending_checkout, false)
|> assign(:loading, false)
|> assign(:error, nil)
{:ok, socket}
end
end
@impl true
def handle_params(%{"success" => "true"}, _uri, socket) do
# Refresh subscription status after successful checkout
user = socket.assigns[:current_user]
product = socket.assigns.product
subscription = if user, do: Subscriptions.get_subscription(user.id, product.slug), else: nil
socket =
case subscription do
%Subscription{} = current ->
if Subscription.has_access?(current) do
socket
|> assign(:subscription, current)
|> assign(:pending_checkout, false)
|> put_flash(
:info,
if(Product.one_time?(product),
do: "Payment completed successfully!",
else: "Subscription activated successfully!"
)
)
else
socket
|> assign(:subscription, current)
|> assign(:pending_checkout, true)
|> put_flash(
:info,
if(Product.one_time?(product),
do: "Checkout completed. Waiting for Stripe to confirm your payment.",
else: "Checkout completed. Waiting for Stripe to confirm your subscription."
)
)
end
_ ->
socket
|> assign(:subscription, subscription)
|> assign(:pending_checkout, true)
|> put_flash(
:info,
if(Product.one_time?(product),
do: "Checkout completed. Waiting for Stripe to confirm your payment.",
else: "Checkout completed. Waiting for Stripe to confirm your subscription."
)
)
end
{:noreply, socket}
end
def handle_params(_params, _uri, socket) do
{:noreply, assign(socket, :pending_checkout, false)}
end
@impl true
def handle_event("checkout", %{"plan" => plan}, socket) do
user = socket.assigns[:current_user]
product = socket.assigns.product
if user == nil do
{:noreply, redirect(socket, to: Elektrine.Paths.login_path())}
else
price_id =
case plan do
"monthly" -> product.stripe_monthly_price_id
"yearly" -> product.stripe_yearly_price_id
"one_time" -> product.stripe_one_time_price_id
_ -> nil
end
checkout_mode =
if plan == "one_time" and Product.one_time?(product), do: :payment, else: :subscription
if Elektrine.Strings.present?(price_id) do
socket = assign(socket, :loading, true)
case Subscriptions.create_checkout_session(user, product.slug, price_id,
checkout_mode: checkout_mode,
success_url:
"#{ElektrineWeb.Endpoint.url()}/subscribe/#{product.slug}?success=true",
cancel_url: "#{ElektrineWeb.Endpoint.url()}/subscribe/#{product.slug}"
) do
{:ok, %{url: checkout_url}} ->
{:noreply, redirect(socket, external: checkout_url)}
{:error, error} ->
{:noreply,
socket
|> assign(:loading, false)
|> assign(:error, "Failed to create checkout session: #{inspect(error)}")}
end
else
{:noreply, assign(socket, :error, "Price not configured. Please contact support.")}
end
end
end
def handle_event("manage", _params, socket) do
user = socket.assigns[:current_user]
product = socket.assigns.product
if user do
socket = assign(socket, :loading, true)
case Subscriptions.create_portal_session(user, product.slug,
return_url: "#{ElektrineWeb.Endpoint.url()}/subscribe/#{product.slug}"
) do
{:ok, %{url: portal_url}} ->
{:noreply, redirect(socket, external: portal_url)}
{:error, _} ->
{:noreply,
socket
|> assign(:loading, false)
|> assign(:error, "Failed to open billing portal")}
end
else
{:noreply, redirect(socket, to: Elektrine.Paths.login_path())}
end
end
def handle_event("cancel", _params, socket) do
subscription = socket.assigns.subscription
if subscription do
case Subscriptions.cancel_subscription(subscription) do
{:ok, updated_sub} ->
{:noreply,
socket
|> assign(:subscription, updated_sub)
|> put_flash(:info, "Subscription will be canceled at the end of the billing period.")}
{:error, _} ->
{:noreply, assign(socket, :error, "Failed to cancel subscription")}
end
else
{:noreply, socket}
end
end
def handle_event("resume", _params, socket) do
subscription = socket.assigns.subscription
if subscription do
case Subscriptions.resume_subscription(subscription) do
{:ok, updated_sub} ->
{:noreply,
socket
|> assign(:subscription, updated_sub)
|> put_flash(:info, "Subscription resumed!")}
{:error, _} ->
{:noreply, assign(socket, :error, "Failed to resume subscription")}
end
else
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 pb-8">
<.elektrine_nav active_tab={@product.slug} current_user={@current_user} />
<div class="text-center mb-12">
<h1 class="text-4xl font-bold text-base-content mb-4">
{@product.name}
</h1>
<%= if @product.description do %>
<p class="text-lg text-base-content/70 max-w-2xl mx-auto">
{@product.description}
</p>
<% end %>
</div>
<%= if @error do %>
<div class="alert alert-error mb-6">
<.icon name="hero-exclamation-circle" class="w-5 h-5" />
<span>{@error}</span>
</div>
<% end %>
<%= if @pending_checkout do %>
<div class="alert alert-info mb-6">
<.icon name="hero-arrow-path" class="w-5 h-5" />
<span>
Your checkout finished, but access is still syncing from Stripe. Refresh this page in a moment if it does not update automatically.
</span>
</div>
<% end %>
<%= if @subscription && Subscription.has_access?(@subscription) do %>
<.subscription_active subscription={@subscription} product={@product} loading={@loading} />
<% else %>
<.pricing_cards
product={@product}
loading={@loading}
current_user={@current_user}
pending_checkout={@pending_checkout}
/>
<% end %>
</div>
"""
end
defp subscription_active(assigns) do
assigns = assign(assigns, :one_time_product, Product.one_time?(assigns.product))
~H"""
<.card class="border border-base-300 max-w-lg mx-auto" body_class="text-center">
<:body>
<div class="w-16 h-16 rounded-full bg-success/20 flex items-center justify-center mx-auto mb-4">
<.icon name="hero-check-circle" class="w-8 h-8 text-success" />
</div>
<h2 class="text-2xl font-bold text-base-content mb-2">
{if @one_time_product, do: "Access Granted", else: "Subscription Active"}
</h2>
<p class="text-base-content/70 mb-4">
<%= if @one_time_product do %>
Your one-time payment for {@product.name} has been recorded.
<% else %>
You have full access to {@product.name}.
<% end %>
</p>
<div class="stats stats-vertical bg-base-200 rounded-box mb-6">
<div class="stat">
<div class="stat-title">Status</div>
<div class="stat-value text-lg capitalize">{@subscription.status}</div>
</div>
<%= if @one_time_product and @subscription.current_period_start do %>
<div class="stat">
<div class="stat-title">Purchased On</div>
<div class="stat-value text-lg">
{Calendar.strftime(@subscription.current_period_start, "%B %d, %Y")}
</div>
</div>
<% end %>
<%= if !@one_time_product and @subscription.current_period_end do %>
<div class="stat">
<div class="stat-title">
{if @subscription.cancel_at_period_end, do: "Access Until", else: "Renews On"}
</div>
<div class="stat-value text-lg">
{Calendar.strftime(@subscription.current_period_end, "%B %d, %Y")}
</div>
</div>
<% end %>
</div>
<%= if !@one_time_product and @subscription.cancel_at_period_end do %>
<div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>
Your subscription will end on {Calendar.strftime(
@subscription.current_period_end,
"%B %d, %Y"
)}
</span>
</div>
<.button phx-click="resume" class="btn-block" disabled={@loading}>
{if @loading, do: "Loading...", else: "Resume Subscription"}
</.button>
<% else %>
<div class="space-y-2">
<%= if @one_time_product do %>
<div class="text-sm text-base-content/60">
This purchase does not renew automatically.
</div>
<% else %>
<.button phx-click="manage" class="btn-block" disabled={@loading}>
{if @loading, do: "Loading...", else: "Manage Subscription"}
</.button>
<.button
phx-click="cancel"
data-confirm="Are you sure you want to cancel? You'll keep access until the end of your billing period."
variant="ghost"
class="btn-block text-error"
disabled={@loading}
>
Cancel Subscription
</.button>
<% end %>
</div>
<% end %>
</:body>
</.card>
"""
end
defp pricing_cards(assigns) do
if Product.one_time?(assigns.product) do
assigns =
assigns
|> assign(:has_one_time, Product.has_one_time?(assigns.product))
|> assign(
:one_time_price,
format_price(assigns.product.one_time_price_cents, assigns.product.currency)
)
~H"""
<div class="max-w-xl mx-auto">
<.card class="border-2 border-primary">
<:body>
<h3 class="text-xl font-semibold text-base-content">One-time Purchase</h3>
<div class="my-4">
<%= if @one_time_price do %>
<span class="text-4xl font-bold text-base-content">{@one_time_price}</span>
<span class="text-base-content/60"> once</span>
<% else %>
<span class="text-2xl text-base-content/50">Price not set</span>
<% end %>
</div>
<ul class="space-y-2 mb-6">
<%= for feature <- @product.features || [] do %>
<li class="flex items-start gap-2">
<.icon name="hero-check" class="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span class="text-base-content/80">{feature}</span>
</li>
<% end %>
</ul>
<%= if @current_user do %>
<%= if @has_one_time do %>
<.button
phx-click="checkout"
phx-value-plan="one_time"
class="btn-block"
disabled={@loading or @pending_checkout}
>
{cond do
@loading -> "Loading..."
@pending_checkout -> "Processing..."
true -> "Pay Once"
end}
</.button>
<% else %>
<.button variant="default" class="btn-disabled btn-block">Coming Soon</.button>
<% end %>
<% else %>
<.button href={Elektrine.Paths.login_path()} class="btn-block">
Log in to Purchase
</.button>
<% end %>
</:body>
</.card>
</div>
<div class="text-center mt-8 text-base-content/60 text-sm">
<p>Secure payment powered by Stripe. One payment, no renewal.</p>
</div>
"""
else
assigns =
assigns
|> assign(:has_monthly, Product.has_monthly?(assigns.product))
|> assign(:has_yearly, Product.has_yearly?(assigns.product))
|> assign(
:monthly_price,
format_price(assigns.product.monthly_price_cents, assigns.product.currency)
)
|> assign(
:yearly_price,
format_price(assigns.product.yearly_price_cents, assigns.product.currency)
)
|> assign(
:monthly_equivalent,
calculate_monthly_equivalent(
assigns.product.yearly_price_cents,
assigns.product.currency
)
)
|> assign(
:savings_percent,
calculate_savings(
assigns.product.monthly_price_cents,
assigns.product.yearly_price_cents
)
)
~H"""
<div class="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
<.card class="border border-base-300 hover:border-secondary/50 transition-colors">
<:body>
<h3 class="text-xl font-semibold text-base-content">Monthly</h3>
<div class="my-4">
<%= if @monthly_price do %>
<span class="text-4xl font-bold text-base-content">{@monthly_price}</span>
<span class="text-base-content/60">/month</span>
<% else %>
<span class="text-2xl text-base-content/50">Price not set</span>
<% end %>
</div>
<ul class="space-y-2 mb-6">
<%= for feature <- @product.features || [] do %>
<li class="flex items-start gap-2">
<.icon name="hero-check" class="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span class="text-base-content/80">{feature}</span>
</li>
<% end %>
</ul>
<%= if @current_user do %>
<%= if @has_monthly do %>
<.button
phx-click="checkout"
phx-value-plan="monthly"
variant="secondary"
class="btn-block"
disabled={@loading or @pending_checkout}
>
{cond do
@loading -> "Loading..."
@pending_checkout -> "Processing..."
true -> "Subscribe Monthly"
end}
</.button>
<% else %>
<.button variant="default" class="btn-disabled btn-block">Coming Soon</.button>
<% end %>
<% else %>
<.button href={Elektrine.Paths.login_path()} variant="secondary" class="btn-block">
Log in to Subscribe
</.button>
<% end %>
</:body>
</.card>
<div class="card panel-card border-2 border-secondary relative">
<%= if @savings_percent && @savings_percent > 0 do %>
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="badge badge-secondary">Save {@savings_percent}%</span>
</div>
<% end %>
<div class="card-body">
<h3 class="text-xl font-semibold text-base-content">Yearly</h3>
<div class="my-4">
<%= if @yearly_price do %>
<span class="text-4xl font-bold text-base-content">{@yearly_price}</span>
<span class="text-base-content/60">/year</span>
<%= if @monthly_equivalent do %>
<div class="text-sm text-base-content/50">{@monthly_equivalent}/month</div>
<% end %>
<% else %>
<span class="text-2xl text-base-content/50">Price not set</span>
<% end %>
</div>
<ul class="space-y-2 mb-6">
<%= for feature <- @product.features || [] do %>
<li class="flex items-start gap-2">
<.icon name="hero-check" class="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span class="text-base-content/80">{feature}</span>
</li>
<% end %>
</ul>
<%= if @current_user do %>
<%= if @has_yearly do %>
<.button
phx-click="checkout"
phx-value-plan="yearly"
class="btn-block"
disabled={@loading or @pending_checkout}
>
{cond do
@loading -> "Loading..."
@pending_checkout -> "Processing..."
true -> "Subscribe Yearly"
end}
</.button>
<% else %>
<.button variant="default" class="btn-disabled btn-block">Coming Soon</.button>
<% end %>
<% else %>
<.button href={Elektrine.Paths.login_path()} class="btn-block">
Log in to Subscribe
</.button>
<% end %>
</div>
</div>
</div>
<div class="text-center mt-8 text-base-content/60 text-sm">
<p>Secure payment powered by Stripe. Cancel anytime.</p>
</div>
"""
end
end
defp format_price(nil, _currency), do: nil
defp format_price(cents, currency) when is_integer(cents) do
Product.format_price(cents, currency)
end
defp calculate_monthly_equivalent(nil, _currency), do: nil
defp calculate_monthly_equivalent(yearly_cents, currency) when is_integer(yearly_cents) do
monthly_cents = div(yearly_cents, 12)
Product.format_price(monthly_cents, currency)
end
defp calculate_savings(nil, _yearly), do: nil
defp calculate_savings(_monthly, nil), do: nil
defp calculate_savings(monthly_cents, yearly_cents) when monthly_cents > 0 do
full_year_at_monthly = monthly_cents * 12
savings = (full_year_at_monthly - yearly_cents) / full_year_at_monthly * 100
round(savings)
end
defp calculate_savings(_, _), do: nil
end

View file

@ -428,13 +428,6 @@ defmodule ElektrineWeb.Router do
get("/health", HealthController, :check)
end
# Stripe webhook (no auth, signature verified in controller)
scope "/webhook", ElektrineWeb do
pipe_through(:api)
post("/stripe", StripeWebhookController, :webhook)
end
# Internal Caddy on-demand TLS allowlist endpoint.
# This stays on the private/origin app hostname and should be called only by the Caddy edge.
scope "/_edge/tls/v1", ElektrineWeb do
@ -681,18 +674,12 @@ defmodule ElektrineWeb.Router do
# Controller routes for form submissions
post("/register", UserRegistrationController, :create)
post("/register/purchase", RegistrationPaymentController, :create)
get("/captcha", CaptchaController, :show)
post("/login", UserSessionController, :create)
post("/password/reset", PasswordResetController, :create)
put("/password/reset/:token", PasswordResetController, :update)
end
scope "/", ElektrineWeb do
pipe_through(:browser)
get("/register/purchase/success", RegistrationPaymentController, :show)
end
# Passkey authentication route (must be before authentication redirect)
scope "/", ElektrineWeb do
@ -935,15 +922,6 @@ defmodule ElektrineWeb.Router do
# Unsubscribe statistics (Admin.ModerationController)
get("/unsubscribe-stats", Admin.ModerationController, :unsubscribe_stats)
# Subscription products management (Admin.SubscriptionsController)
get("/subscriptions", Admin.SubscriptionsController, :index)
get("/subscriptions/new", Admin.SubscriptionsController, :new)
post("/subscriptions", Admin.SubscriptionsController, :create)
get("/subscriptions/:id/edit", Admin.SubscriptionsController, :edit)
put("/subscriptions/:id", Admin.SubscriptionsController, :update)
delete("/subscriptions/:id", Admin.SubscriptionsController, :delete)
post("/subscriptions/:id/toggle", Admin.SubscriptionsController, :toggle)
ElektrineWeb.Routes.VPN.admin_routes()
end
@ -1973,8 +1951,6 @@ defmodule ElektrineWeb.Router do
live("/proofs", AtomineProofsLive.Show, :index)
live("/proofs/:handle", AtomineProofsLive.Show, :show)
# Subscription pages
live("/subscribe/:product", SubscribeLive, :index)
# === Authenticated routes (auth checked in mount via current_user assign) ===

View file

@ -1,137 +0,0 @@
defmodule ElektrineWeb.RegistrationPaymentControllerTest do
use ElektrineWeb.ConnCase, async: false
alias Elektrine.Accounts
alias Elektrine.Repo
alias Elektrine.Subscriptions.{Product, RegistrationCheckout}
defmodule FakeStripeClient do
@behaviour Elektrine.Subscriptions.StripeClient
@impl true
def create_customer(_params), do: {:error, :unsupported}
@impl true
def create_checkout_session(params), do: dispatch(:create_checkout_session, params)
@impl true
def create_billing_portal_session(_params), do: {:error, :unsupported}
@impl true
def update_subscription(_subscription_id, _params), do: {:error, :unsupported}
@impl true
def retrieve_price(_price_id), do: {:error, :unsupported}
defp dispatch(name, payload) do
case Process.get({__MODULE__, name}) do
fun when is_function(fun, 1) -> fun.(payload)
nil -> raise "missing fake Stripe expectation for #{inspect(name)}"
end
end
end
setup do
previous_client = Application.get_env(:elektrine, :stripe_client)
Application.put_env(:elektrine, :stripe_client, FakeStripeClient)
Process.put(
{FakeStripeClient, :create_checkout_session},
fn _payload -> flunk("unexpected Stripe checkout session call") end
)
on_exit(fn ->
if previous_client do
Application.put_env(:elektrine, :stripe_client, previous_client)
else
Application.delete_env(:elektrine, :stripe_client)
end
end)
:ok
end
test "POST /register/purchase redirects to Stripe checkout", %{conn: conn} do
Repo.insert!(%Product{
name: "Registration",
slug: "registration",
billing_type: "one_time",
currency: "usd",
active: true,
one_time_price_cents: 500,
stripe_one_time_price_id: "price_once"
})
expect_stripe(:create_checkout_session, fn params ->
assert params.mode == "payment"
assert params.customer_creation == "always"
assert params.metadata.purpose == "registration_invite"
{:ok, %{id: "cs_reg_redirect", url: "https://checkout.test/register"}}
end)
conn = post(conn, ~p"/register/purchase")
assert redirected_to(conn) == "https://checkout.test/register"
end
test "GET /register/purchase/success shows the issued invite code", %{conn: conn} do
{:ok, invite_code} = Accounts.create_invite_code(%{max_uses: 1, note: "paid"})
Repo.insert!(%RegistrationCheckout{
stripe_checkout_session_id: "cs_reg_success",
lookup_token: RegistrationCheckout.hash_lookup_token("access-token"),
product_slug: "registration",
status: "fulfilled",
invite_code_id: invite_code.id
})
conn =
get(
conn,
~p"/register/purchase/success?checkout_session_id=cs_reg_success&access=access-token"
)
assert redirected_to(conn) == "/register/purchase/success?checkout_session_id=cs_reg_success"
conn =
conn
|> Phoenix.ConnTest.recycle()
|> get(~p"/register/purchase/success?checkout_session_id=cs_reg_success")
response = html_response(conn, 200)
assert response =~ "Invite Ready"
assert response =~ invite_code.code
assert response =~ "/register?invite_code=#{invite_code.code}"
end
test "GET /register/purchase/success shows a pending state before fulfillment", %{conn: conn} do
Repo.insert!(%RegistrationCheckout{
stripe_checkout_session_id: "cs_reg_pending",
lookup_token: RegistrationCheckout.hash_lookup_token("pending-token"),
product_slug: "registration",
status: "pending"
})
conn =
get(
conn,
~p"/register/purchase/success?checkout_session_id=cs_reg_pending&access=pending-token"
)
assert redirected_to(conn) == "/register/purchase/success?checkout_session_id=cs_reg_pending"
conn =
conn
|> Phoenix.ConnTest.recycle()
|> get(~p"/register/purchase/success?checkout_session_id=cs_reg_pending")
response = html_response(conn, 200)
assert response =~ "Payment Received"
assert response =~ "still being matched to an invite code"
end
defp expect_stripe(name, fun) do
Process.put({FakeStripeClient, name}, fun)
end
end

View file

@ -4,8 +4,6 @@ defmodule ElektrineWeb.UserRegistrationControllerTest do
alias Elektrine.Accounts
alias Elektrine.AccountsFixtures
alias Elektrine.Domains
alias Elektrine.Repo
alias Elektrine.Subscriptions.Product
alias Elektrine.System, as: SystemSettings
import Elektrine.DataCase, only: [errors_on: 1]
@ -24,26 +22,6 @@ defmodule ElektrineWeb.UserRegistrationControllerTest do
assert response =~ "Password"
end
test "shows the paid invite CTA when invite codes are enabled and registration product exists",
%{conn: conn} do
{:ok, _config} = SystemSettings.set_invite_codes_enabled(true)
Repo.insert!(%Product{
name: "Registration",
slug: "registration",
billing_type: "one_time",
currency: "usd",
active: true,
one_time_price_cents: 500,
stripe_one_time_price_id: "price_once"
})
conn = get(conn, ~p"/register")
response = html_response(conn, 200)
assert response =~ "Pay One-Time Fee"
assert response =~ "$5.00"
end
test "redirects if already logged in", %{conn: conn} do
{:ok, user} =
Accounts.create_user(%{

View file

@ -1,73 +0,0 @@
defmodule ElektrineWeb.SubscribeLiveTest do
use ElektrineWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Elektrine.{AccountsFixtures, Repo}
alias Elektrine.Subscriptions.{Product, Subscription}
defp log_in_user(conn, user) do
token =
Phoenix.Token.sign(ElektrineWeb.Endpoint, "user auth", %{
"user_id" => user.id,
"password_changed_at" =>
user.last_password_change && DateTime.to_unix(user.last_password_change),
"auth_valid_after" => user.auth_valid_after && DateTime.to_unix(user.auth_valid_after)
})
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
end
test "shows a pending state after checkout success until webhook confirmation", %{conn: conn} do
user = AccountsFixtures.user_fixture()
product =
Repo.insert!(%Product{
name: "VPN",
slug: "vpn",
description: "Private network access",
currency: "usd",
active: true,
monthly_price_cents: 1900,
yearly_price_cents: 19_000,
stripe_monthly_price_id: "price_month",
stripe_yearly_price_id: "price_year"
})
Repo.insert!(%Subscription{
user_id: user.id,
product: product.slug,
status: "incomplete"
})
{:ok, view, html} =
conn
|> log_in_user(user)
|> live(~p"/subscribe/#{product.slug}?success=true")
assert html =~ "access is still syncing from Stripe"
assert render(view) =~ "Processing..."
end
test "renders one-time purchase copy for one-time products", %{conn: conn} do
product =
Repo.insert!(%Product{
name: "Registration",
slug: "registration",
description: "Pay once to register",
billing_type: "one_time",
currency: "usd",
active: true,
one_time_price_cents: 500,
stripe_one_time_price_id: "price_once"
})
{:ok, _view, html} = live(conn, ~p"/subscribe/#{product.slug}")
assert html =~ "One-time Purchase"
assert html =~ "Log in to Purchase"
assert html =~ "$5.00"
end
end

View file

@ -628,11 +628,6 @@ config :elektrine, :atomine_gate,
difficulty: 20,
clearance_ttl_seconds: 12 * 60 * 60
# Stripe configuration (defaults for development, override in runtime.exs)
config :stripity_stripe,
api_key: nil,
signing_secret: nil
# Media proxy (federation remote media)
config :elektrine, :media_proxy,
enabled: false,

View file

@ -1278,5 +1278,4 @@ Code.eval_file(Path.expand("runtime/mail_protocols.exs", __DIR__))
Code.eval_file(Path.expand("runtime/dns.exs", __DIR__))
Code.eval_file(Path.expand("runtime/vpn.exs", __DIR__))
Code.eval_file(Path.expand("runtime/messaging_federation.exs", __DIR__))
Code.eval_file(Path.expand("runtime/stripe.exs", __DIR__))
Code.eval_file(Path.expand("runtime/monero.exs", __DIR__))

View file

@ -1,7 +0,0 @@
import Config
if System.get_env("STRIPE_SECRET_KEY") do
config :stripity_stripe,
api_key: System.get_env("STRIPE_SECRET_KEY"),
signing_secret: System.get_env("STRIPE_WEBHOOK_SECRET")
end

View file

@ -73,7 +73,6 @@
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stripity_stripe": {:hex, :stripity_stripe, "3.3.2", "795fb7d56baa2fb0d80a44d4fe23b4e947809e8d61e80d9f5b5eba64610f63e2", [:mix], [{:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uri_query, "~> 0.2.0", [hex: :uri_query, repo: "hexpm", optional: false]}], "hexpm", "73db5ab782f1eec9dac0c9cc7b5391452c704960ddfff42acdba83dbe37b5011"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.26.3", "9d8b60077305ce259298d9a1102e5be67cd3c41d1ea930c29e9288af195ca017", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c7683d070fe8f8aa9d174e61b01f2d527be73cd8ac40037b7109184941eb569f"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},