Packages
phoenix_kit
1.7.8
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/billing/subscription.ex
defmodule PhoenixKit.Billing.Subscription do
@moduledoc """
Schema for subscriptions (master record).
Subscriptions are controlled internally by PhoenixKit, NOT by payment providers.
This allows using any payment provider (even those without subscription APIs)
and provides full control over subscription lifecycle.
## Status Lifecycle
```
trialing -> active -> [past_due -> active] -> cancelled
-> paused -> active
-> cancelled
```
- `trialing` - Free trial period active
- `active` - Subscription is active and paid
- `past_due` - Payment failed, in grace period
- `paused` - Subscription temporarily paused by user
- `cancelled` - Subscription ended
## Renewal Process
Renewals are handled by Oban workers:
1. `SubscriptionRenewalWorker` runs daily, checks subscriptions near period end
2. Creates invoice for the subscription
3. Charges saved payment method via provider
4. On success: extends `current_period_end`
5. On failure: sets status to `past_due`, increments `renewal_attempts`
## Grace Period (Dunning)
When payment fails:
1. Status changes to `past_due`
2. `grace_period_end` is set (configurable days)
3. `SubscriptionDunningWorker` retries payment
4. After max attempts or grace period end: subscription cancelled
"""
use Ecto.Schema
import Ecto.Changeset
alias PhoenixKit.Billing.{BillingProfile, PaymentMethod, SubscriptionPlan}
@statuses ~w(trialing active past_due paused cancelled)
schema "phoenix_kit_subscriptions" do
field :status, :string, default: "active"
# Billing period
field :current_period_start, :utc_datetime
field :current_period_end, :utc_datetime
# Cancellation
field :cancel_at_period_end, :boolean, default: false
field :cancelled_at, :utc_datetime
# Trial
field :trial_start, :utc_datetime
field :trial_end, :utc_datetime
# Dunning (failed payment handling)
field :grace_period_end, :utc_datetime
field :renewal_attempts, :integer, default: 0
field :last_renewal_attempt_at, :utc_datetime
# Metadata
field :metadata, :map, default: %{}
# Associations
field :user_id, :integer
belongs_to :billing_profile, BillingProfile
belongs_to :plan, SubscriptionPlan
belongs_to :payment_method, PaymentMethod
timestamps(type: :utc_datetime)
end
@doc """
Creates a changeset for creating a new subscription.
"""
def changeset(subscription, attrs) do
subscription
|> cast(attrs, [
:status,
:current_period_start,
:current_period_end,
:cancel_at_period_end,
:cancelled_at,
:trial_start,
:trial_end,
:grace_period_end,
:renewal_attempts,
:last_renewal_attempt_at,
:metadata,
:user_id,
:billing_profile_id,
:plan_id,
:payment_method_id
])
|> validate_required([:user_id, :plan_id, :current_period_start, :current_period_end])
|> validate_inclusion(:status, @statuses)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:billing_profile_id)
|> foreign_key_constraint(:plan_id)
|> foreign_key_constraint(:payment_method_id)
end
@doc """
Changeset for activating a subscription after successful payment.
"""
def activate_changeset(subscription, period_end) do
subscription
|> change(%{
status: "active",
current_period_end: period_end,
renewal_attempts: 0,
grace_period_end: nil
})
end
@doc """
Changeset for marking subscription as past_due.
"""
def past_due_changeset(subscription, grace_period_end) do
subscription
|> change(%{
status: "past_due",
grace_period_end: grace_period_end,
renewal_attempts: subscription.renewal_attempts + 1,
last_renewal_attempt_at: DateTime.utc_now()
})
end
@doc """
Changeset for pausing a subscription.
"""
def pause_changeset(subscription) do
subscription
|> change(%{status: "paused"})
end
@doc """
Changeset for resuming a paused subscription.
"""
def resume_changeset(subscription) do
subscription
|> change(%{status: "active"})
end
@doc """
Changeset for cancelling a subscription.
"""
def cancel_changeset(subscription, immediately \\ false) do
if immediately do
subscription
|> change(%{
status: "cancelled",
cancelled_at: DateTime.utc_now()
})
else
subscription
|> change(%{
cancel_at_period_end: true
})
end
end
@doc """
Changeset for starting a trial.
"""
def trial_changeset(subscription, trial_end) do
subscription
|> change(%{
status: "trialing",
trial_start: DateTime.utc_now(),
trial_end: trial_end
})
end
# ============================================
# Status Helpers
# ============================================
@doc """
Returns true if the subscription is currently active (can use service).
"""
def active?(%__MODULE__{status: status}) when status in ["active", "trialing", "past_due"] do
true
end
def active?(_), do: false
@doc """
Returns true if the subscription is in trial period.
"""
def trialing?(%__MODULE__{status: "trialing"}), do: true
def trialing?(_), do: false
@doc """
Returns true if the subscription is past due (payment failed).
"""
def past_due?(%__MODULE__{status: "past_due"}), do: true
def past_due?(_), do: false
@doc """
Returns true if the subscription is cancelled.
"""
def cancelled?(%__MODULE__{status: "cancelled"}), do: true
def cancelled?(_), do: false
@doc """
Returns true if the subscription is paused.
"""
def paused?(%__MODULE__{status: "paused"}), do: true
def paused?(_), do: false
@doc """
Returns true if the subscription will be cancelled at period end.
"""
def cancelling?(%__MODULE__{cancel_at_period_end: true}), do: true
def cancelling?(_), do: false
@doc """
Returns true if renewal is due (period end is near or past).
"""
def renewal_due?(%__MODULE__{current_period_end: period_end}) when not is_nil(period_end) do
DateTime.compare(period_end, DateTime.utc_now()) != :gt
end
def renewal_due?(_), do: false
@doc """
Returns true if we should attempt renewal (within 24 hours of period end).
"""
def should_renew?(%__MODULE__{current_period_end: period_end, status: status})
when status in ["active", "trialing"] and not is_nil(period_end) do
hours_until_end = DateTime.diff(period_end, DateTime.utc_now(), :hour)
hours_until_end <= 24
end
def should_renew?(_), do: false
@doc """
Returns true if grace period has expired.
"""
def grace_period_expired?(%__MODULE__{grace_period_end: nil}), do: false
def grace_period_expired?(%__MODULE__{grace_period_end: grace_end}) do
DateTime.compare(grace_end, DateTime.utc_now()) != :gt
end
@doc """
Returns the number of days remaining in the current period.
"""
def days_remaining(%__MODULE__{current_period_end: nil}), do: 0
def days_remaining(%__MODULE__{current_period_end: period_end}) do
case DateTime.diff(period_end, DateTime.utc_now(), :day) do
days when days > 0 -> days
_ -> 0
end
end
end