Packages
phoenix_kit
1.7.71
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_dunning_worker.ex
defmodule PhoenixKit.Modules.Billing.Workers.SubscriptionDunningWorker do
@moduledoc """
Oban worker for dunning (failed payment recovery).
When a subscription payment fails, the subscription enters `past_due` status
and this worker handles retry attempts during the grace period.
## Dunning Process
1. Initial payment fails → subscription status = `past_due`
2. Grace period starts (configurable, default 3 days)
3. This worker retries payment at intervals
4. If payment succeeds → status = `active`
5. If max attempts reached or grace period ends → status = `cancelled`
## Retry Schedule
Default retry schedule (can be configured):
- Attempt 1: Immediate (handled by RenewalWorker)
- Attempt 2: 24 hours later
- Attempt 3: 48 hours later (2 days)
- Attempt 4: 72 hours later (3 days, grace period ends)
## Configuration
```elixir
# Settings (stored in database)
billing_subscription_grace_days: 3
billing_dunning_max_attempts: 3
```
## Manual Trigger
```elixir
%{subscription_uuid: "019145a1-0000-7000-8000-000000000001"}
|> SubscriptionDunningWorker.new()
|> Oban.insert()
```
"""
use Oban.Worker,
queue: :billing,
max_attempts: 5,
unique: [period: 3600, keys: [:subscription_uuid]]
alias PhoenixKit.Modules.Billing.{PaymentMethod, Providers, Subscription, SubscriptionType}
alias PhoenixKit.RepoHelper
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
subscription_uuid = Map.get(args, "subscription_uuid") || Map.get(args, "subscription_id")
case get_subscription_with_preloads(subscription_uuid) do
nil ->
Logger.warning("Subscription #{subscription_uuid} not found for dunning")
:ok
subscription ->
process_dunning(subscription)
end
end
# ============================================
# Dunning Processing
# ============================================
defp process_dunning(%Subscription{status: "cancelled"}) do
# Already cancelled, nothing to do
:ok
end
defp process_dunning(%Subscription{status: status}) when status not in ["past_due"] do
# Not in past_due status, skip
:ok
end
defp process_dunning(%Subscription{} = subscription) do
max_attempts = get_max_attempts()
cond do
subscription.renewal_attempts >= max_attempts ->
Logger.info("Subscription #{subscription.uuid} exceeded max dunning attempts, cancelling")
cancel_subscription(subscription, "Max payment retry attempts exceeded")
Subscription.grace_period_expired?(subscription) ->
Logger.info("Subscription #{subscription.uuid} grace period expired, cancelling")
cancel_subscription(subscription, "Grace period expired")
true ->
attempt_payment_retry(subscription)
end
end
defp attempt_payment_retry(%Subscription{payment_method: nil} = subscription) do
Logger.warning("Subscription #{subscription.uuid} has no payment method for retry")
# Still schedule next retry in case user adds payment method
schedule_next_retry(subscription)
{:ok, :no_payment_method}
end
defp attempt_payment_retry(%Subscription{} = subscription) do
pm = subscription.payment_method
if PaymentMethod.usable?(pm) do
Logger.info(
"Attempting payment retry ##{subscription.renewal_attempts + 1} for subscription #{subscription.uuid}"
)
case charge_subscription(subscription) do
{:ok, _result} ->
Logger.info("Dunning payment successful for subscription #{subscription.uuid}")
reactivate_subscription(subscription)
{:error, reason} ->
Logger.warning(
"Dunning payment failed for subscription #{subscription.uuid}: #{inspect(reason)}"
)
update_retry_count(subscription)
schedule_next_retry(subscription)
{:error, reason}
end
else
Logger.warning(
"Payment method not usable for subscription #{subscription.uuid}: #{inspect(pm.status)}"
)
schedule_next_retry(subscription)
{:error, :payment_method_not_usable}
end
end
defp charge_subscription(%Subscription{} = subscription) do
plan = subscription.subscription_type
pm = subscription.payment_method
# Providers.charge_payment_method expects the payment_method map with :provider key
Providers.charge_payment_method(pm, plan.price,
currency: plan.currency,
description: "Subscription renewal (dunning retry)",
metadata: %{
subscription_uuid: subscription.uuid,
retry_attempt: subscription.renewal_attempts + 1
}
)
end
defp reactivate_subscription(%Subscription{} = subscription) do
plan = subscription.subscription_type
new_period_start = subscription.current_period_end
new_period_end = SubscriptionType.next_billing_date(plan, DateTime.to_date(new_period_start))
subscription
|> Subscription.activate_changeset(datetime_from_date(new_period_end))
|> RepoHelper.repo().update()
end
defp update_retry_count(%Subscription{} = subscription) do
subscription
|> Ecto.Changeset.change(%{
renewal_attempts: subscription.renewal_attempts + 1,
last_renewal_attempt_at: UtilsDate.utc_now()
})
|> RepoHelper.repo().update()
end
defp cancel_subscription(%Subscription{} = subscription, reason) do
Logger.info("Cancelling subscription #{subscription.uuid}: #{reason}")
subscription
|> Subscription.cancel_changeset(true)
|> RepoHelper.repo().update()
end
defp schedule_next_retry(%Subscription{} = subscription) do
max_attempts = get_max_attempts()
if subscription.renewal_attempts < max_attempts do
# Schedule next retry in 24 hours
%{subscription_uuid: subscription.uuid}
|> __MODULE__.new(schedule_in: 86_400)
|> Oban.insert()
end
end
# ============================================
# Queries & Helpers
# ============================================
defp get_subscription_with_preloads(uuid) when is_binary(uuid) do
import Ecto.Query
from(s in Subscription,
where: s.uuid == ^uuid,
preload: [:subscription_type, :payment_method]
)
|> RepoHelper.repo().one()
end
defp get_max_attempts do
Settings.get_setting("billing_dunning_max_attempts", "3")
|> String.to_integer()
end
defp datetime_from_date(date) do
DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
end
end