refactor: remove Stripe billing subscriptions
All checks were successful
Deploy Docker Images / Build, push, and deploy (push) Successful in 18m29s
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:
parent
4708fde5a3
commit
3ab7c69b56
38 changed files with 41 additions and 4421 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 Feature 2 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>
|
||||
|
|
@ -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 Feature 2 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
defmodule ElektrineWeb.RegistrationPaymentHTML do
|
||||
@moduledoc """
|
||||
Templates for paid registration checkout.
|
||||
"""
|
||||
|
||||
use ElektrineWeb, :html
|
||||
|
||||
embed_templates "registration_payment_html/*"
|
||||
end
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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) ===
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(%{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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__))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
1
mix.lock
1
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue