Packages
phoenix_kit
1.7.22
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/modules/billing/workers/subscription_renewal_worker.ex
defmodule PhoenixKit.Modules.Billing.Workers.SubscriptionRenewalWorker do
@moduledoc """
Oban worker for processing subscription renewals.
This worker runs daily and handles:
- Finding subscriptions due for renewal (within 24 hours of period end)
- Creating invoices for the renewal
- Charging saved payment methods via providers
- Updating subscription periods on success
- Moving to past_due status on failure
## Scheduling
The worker should be scheduled to run daily via Oban crontab:
```elixir
config :my_app, Oban,
queues: [default: 10, billing: 5],
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"0 6 * * *", PhoenixKit.Modules.Billing.Workers.SubscriptionRenewalWorker}
]}
]
```
## Process Flow
1. Query subscriptions where `current_period_end` is within 24 hours
2. For each subscription:
a. Skip if cancel_at_period_end is true
b. Create renewal invoice
c. Charge saved payment method
d. On success: extend period_end, update invoice as paid
e. On failure: set past_due, schedule dunning
## Manual Trigger
Can be triggered manually for a specific subscription:
```elixir
%{subscription_id: 123}
|> SubscriptionRenewalWorker.new()
|> Oban.insert()
```
"""
use Oban.Worker,
queue: :billing,
max_attempts: 3,
unique: [period: 3600]
alias PhoenixKit.Modules.Billing
alias PhoenixKit.Modules.Billing.{PaymentMethod, Providers, Subscription, SubscriptionPlan}
alias PhoenixKit.Modules.Billing.Workers.SubscriptionDunningWorker
alias PhoenixKit.RepoHelper
alias PhoenixKit.Settings
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"subscription_id" => subscription_id}}) do
# Process single subscription
case get_subscription(subscription_id) do
nil ->
Logger.warning("Subscription #{subscription_id} not found for renewal")
:ok
subscription ->
process_subscription_renewal(subscription)
end
end
def perform(%Oban.Job{args: _args}) do
# Process all due subscriptions (daily batch)
subscriptions = find_subscriptions_due_for_renewal()
Logger.info("Found #{length(subscriptions)} subscriptions due for renewal")
Enum.each(subscriptions, fn subscription ->
case process_subscription_renewal(subscription) do
{:ok, _} ->
Logger.info("Renewed subscription #{subscription.id}")
{:error, reason} ->
Logger.warning("Failed to renew subscription #{subscription.id}: #{inspect(reason)}")
end
end)
:ok
end
# ============================================
# Renewal Processing
# ============================================
defp process_subscription_renewal(%Subscription{cancel_at_period_end: true} = subscription) do
# Subscription marked for cancellation - don't renew, cancel now
Logger.info("Subscription #{subscription.id} marked for cancellation, cancelling now")
subscription
|> Subscription.cancel_changeset(true)
|> RepoHelper.repo().update()
end
defp process_subscription_renewal(%Subscription{} = subscription) do
repo = RepoHelper.repo()
with {:ok, subscription} <- repo.preload(subscription, [:plan, :payment_method]),
{:ok, invoice} <- create_renewal_invoice(subscription),
{:ok, _} <- charge_payment_method(subscription, invoice) do
# Payment successful - extend period
plan = subscription.plan
new_period_start = subscription.current_period_end
new_period_end =
SubscriptionPlan.next_billing_date(plan, DateTime.to_date(new_period_start))
subscription
|> Subscription.activate_changeset(datetime_from_date(new_period_end))
|> repo.update()
else
{:error, :no_payment_method} ->
Logger.warning("Subscription #{subscription.id} has no payment method")
handle_payment_failure(subscription, "No payment method configured")
{:error, reason} ->
handle_payment_failure(subscription, inspect(reason))
end
end
defp create_renewal_invoice(%Subscription{plan: nil}) do
{:error, :no_plan}
end
defp create_renewal_invoice(%Subscription{} = subscription) do
plan = subscription.plan
line_items = [
%{
"name" => "#{plan.name} subscription",
"description" => "#{SubscriptionPlan.interval_description(plan)}",
"quantity" => 1,
"unit_price" => plan.price,
"total" => plan.price
}
]
invoice_attrs = %{
billing_profile_id: subscription.billing_profile_id,
subscription_id: subscription.id,
currency: plan.currency,
status: "sent",
due_date: Date.utc_today(),
notes: "Subscription renewal: #{plan.name}",
line_items: line_items,
subtotal: plan.price,
total: plan.price
}
case Billing.create_invoice(subscription.user_id, invoice_attrs) do
{:ok, invoice} -> {:ok, invoice}
error -> error
end
end
defp charge_payment_method(%Subscription{payment_method: nil}, _invoice) do
{:error, :no_payment_method}
end
defp charge_payment_method(%Subscription{payment_method: pm} = subscription, invoice) do
if PaymentMethod.usable?(pm) do
# Providers.charge_payment_method expects the payment_method map with :provider key
case Providers.charge_payment_method(pm, invoice.total,
currency: invoice.currency,
description: "Subscription renewal",
metadata: %{
invoice_id: invoice.id,
subscription_id: subscription.id
}
) do
{:ok, charge_result} ->
# Record payment on invoice
payment_attrs = %{
amount: invoice.total,
payment_method: pm.provider,
description: "Subscription renewal payment",
provider_transaction_id: charge_result[:charge_id],
provider_data: charge_result
}
Billing.record_payment(invoice, payment_attrs, nil)
{:error, reason} ->
{:error, reason}
end
else
{:error, :payment_method_not_usable}
end
end
defp handle_payment_failure(%Subscription{} = subscription, error_message) do
grace_days =
Settings.get_setting("billing_subscription_grace_days", "3") |> String.to_integer()
grace_period_end = DateTime.add(DateTime.utc_now(), grace_days, :day)
Logger.warning(
"Subscription #{subscription.id} renewal failed: #{error_message}. Grace period until #{grace_period_end}"
)
result =
subscription
|> Subscription.past_due_changeset(grace_period_end)
|> RepoHelper.repo().update()
# Schedule dunning job
schedule_dunning(subscription.id)
result
end
defp schedule_dunning(subscription_id) do
# Schedule dunning worker to retry in 24 hours
%{subscription_id: subscription_id}
|> SubscriptionDunningWorker.new(schedule_in: 86_400)
|> Oban.insert()
end
# ============================================
# Queries
# ============================================
defp find_subscriptions_due_for_renewal do
import Ecto.Query
# Find subscriptions where:
# - Status is active or trialing
# - Period end is within next 24 hours
# - Not already marked for cancellation
cutoff = DateTime.add(DateTime.utc_now(), 24, :hour)
from(s in Subscription,
where: s.status in ["active", "trialing"],
where: s.current_period_end <= ^cutoff,
where: s.cancel_at_period_end == false
)
|> RepoHelper.repo().all()
end
defp get_subscription(id) do
RepoHelper.repo().get(Subscription, id)
end
defp datetime_from_date(date) do
DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
end
end