Current section

Files

Jump to
accrue lib accrue billing subscription.ex
Raw

lib/accrue/billing/subscription.ex

defmodule Accrue.Billing.Subscription do
@moduledoc """
Ecto schema for the `accrue_subscriptions` table.
Stores the local projection of a Stripe subscription. `:status` is an
`Ecto.Enum` over the full Stripe subscription status set, and the schema
includes `cancel_at_period_end`, `pause_collection`, and other lifecycle
fields needed to answer all common billing questions locally.
## Use the predicates, not raw `.status`
Do not gate business logic on direct comparisons to `.status`. Raw
status checks are easy to get wrong — for example, a subscription with
`cancel_at_period_end: true` still has status `:active`, and an
`:incomplete_expired` subscription is just as terminated as a
`:canceled` one.
The predicates in this module capture those edge cases correctly:
- `trialing?/1`
- `active?/1` — includes `:trialing`
- `past_due?/1` — `:past_due` or `:unpaid`
- `canceled?/1` — `:canceled`, `:incomplete_expired`, or any `ended_at`
- `canceling?/1` — `:active` + `cancel_at_period_end` + future period end
- `paused?/1` — legacy `:paused` status OR non-nil `pause_collection`
- `entitling?/1` — pure-lifecycle entitlement grant (active, not paused, not terminated)
For database queries over multiple subscriptions, the matching fragments
are in `Accrue.Billing.Query`.
"""
use Ecto.Schema
import Ecto.Changeset
alias Accrue.Billing.Metadata
@statuses [
:trialing,
:active,
:past_due,
:canceled,
:unpaid,
:incomplete,
:incomplete_expired,
:paused
]
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@type t :: %__MODULE__{}
schema "accrue_subscriptions" do
belongs_to(:customer, Accrue.Billing.Customer)
field(:processor, :string)
field(:processor_id, :string)
field(:status, Ecto.Enum, values: @statuses)
field(:cancel_at_period_end, :boolean, default: false)
field(:pause_collection, :map)
field(:paused_at, :utc_datetime_usec)
field(:pause_behavior, :string)
field(:past_due_since, :utc_datetime_usec)
field(:dunning_sweep_attempted_at, :utc_datetime_usec)
field(:dunning_campaign_started_at, :utc_datetime_usec)
field(:discount_id, :string)
field(:automatic_tax, :boolean, default: false)
field(:automatic_tax_status, :string)
field(:automatic_tax_disabled_reason, :string)
field(:current_period_start, :utc_datetime_usec)
field(:current_period_end, :utc_datetime_usec)
field(:trial_start, :utc_datetime_usec)
field(:trial_end, :utc_datetime_usec)
field(:cancel_at, :utc_datetime_usec)
field(:canceled_at, :utc_datetime_usec)
field(:ended_at, :utc_datetime_usec)
field(:last_stripe_event_ts, :utc_datetime_usec)
field(:last_stripe_event_id, :string)
field(:metadata, :map, default: %{})
field(:data, :map, default: %{})
field(:lock_version, :integer, default: 1)
has_many(:subscription_items, Accrue.Billing.SubscriptionItem)
timestamps(type: :utc_datetime_usec)
end
@cast_fields ~w[
customer_id processor processor_id status
cancel_at_period_end pause_collection
paused_at pause_behavior past_due_since dunning_sweep_attempted_at
dunning_campaign_started_at discount_id
automatic_tax automatic_tax_status automatic_tax_disabled_reason
current_period_start current_period_end
trial_start trial_end cancel_at canceled_at ended_at
last_stripe_event_ts last_stripe_event_id
metadata data lock_version
]a
@required_fields ~w[customer_id processor]a
@doc """
Webhook-path changeset. Skips user-path validation guards so
out-of-order webhook events can settle arbitrary state without the
state-machine check failing on an otherwise-valid transition.
"""
@spec force_status_changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def force_status_changeset(subscription_or_changeset, attrs \\ %{}) do
subscription_or_changeset
|> cast(attrs, @cast_fields)
|> Metadata.validate_metadata(:metadata)
|> optimistic_lock(:lock_version)
|> foreign_key_constraint(:customer_id)
end
@doc "Canonical list of subscription statuses (Stripe's 8 values)."
@spec statuses() :: [atom()]
def statuses, do: @statuses
@doc """
Builds a changeset for creating or updating a Subscription.
"""
@spec changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(subscription_or_changeset, attrs \\ %{}) do
subscription_or_changeset
|> cast(attrs, @cast_fields)
|> validate_required(@required_fields)
|> Metadata.validate_metadata(:metadata)
|> optimistic_lock(:lock_version)
|> foreign_key_constraint(:customer_id)
end
# ---------------------------------------------------------------------------
# BILL-05 predicates
# ---------------------------------------------------------------------------
@doc "True if the subscription is currently in a trial."
@spec trialing?(%__MODULE__{} | map()) :: boolean()
def trialing?(%__MODULE__{status: :trialing}), do: true
def trialing?(%{status: :trialing}), do: true
def trialing?(_), do: false
@doc """
True if the subscription counts as "active" for entitlement purposes.
Includes `:trialing` — a customer in a trial period has full access.
"""
@spec active?(%__MODULE__{} | map()) :: boolean()
def active?(%__MODULE__{status: s}) when s in [:active, :trialing], do: true
def active?(%{status: s}) when s in [:active, :trialing], do: true
def active?(_), do: false
@doc "True if the subscription is past due or unpaid (dunning territory)."
@spec past_due?(%__MODULE__{} | map()) :: boolean()
def past_due?(%__MODULE__{status: s}) when s in [:past_due, :unpaid], do: true
def past_due?(%{status: s}) when s in [:past_due, :unpaid], do: true
def past_due?(_), do: false
@doc """
True if the subscription has terminated.
`:canceled`, `:incomplete_expired`, or any row with a non-nil `ended_at`.
"""
@spec canceled?(%__MODULE__{} | map()) :: boolean()
def canceled?(%__MODULE__{status: s}) when s in [:canceled, :incomplete_expired], do: true
def canceled?(%__MODULE__{ended_at: %DateTime{}}), do: true
def canceled?(%{status: s}) when s in [:canceled, :incomplete_expired], do: true
def canceled?(%{ended_at: %DateTime{}}), do: true
def canceled?(_), do: false
@doc """
True if the subscription is `:active` with `cancel_at_period_end` set and
the current period end is still in the future (cancel_at_period_end cancel
hasn't taken effect yet).
"""
@spec canceling?(%__MODULE__{} | map()) :: boolean()
def canceling?(%__MODULE__{
status: :active,
cancel_at_period_end: true,
current_period_end: %DateTime{} = cpe
}) do
DateTime.compare(cpe, Accrue.Clock.utc_now()) == :gt
end
def canceling?(%{
status: :active,
cancel_at_period_end: true,
current_period_end: %DateTime{} = cpe
}) do
DateTime.compare(cpe, Accrue.Clock.utc_now()) == :gt
end
def canceling?(_), do: false
@doc """
True if the subscription is paused.
Covers both the legacy `:paused` status (used by earlier Stripe API
versions) and the modern `pause_collection` map returned by current
Stripe versions.
"""
@spec paused?(%__MODULE__{} | map()) :: boolean()
def paused?(%__MODULE__{pause_collection: pc}) when is_map(pc), do: true
def paused?(%__MODULE__{status: :paused}), do: true
def paused?(%{pause_collection: pc}) when is_map(pc), do: true
def paused?(%{status: :paused}), do: true
def paused?(_), do: false
@doc """
True iff the subscription's pure lifecycle grants entitlement.
Active (including `:trialing` and a paid-through `cancel_at_period_end`
row) AND not paused AND not terminated. This is the single source of
truth for which lifecycle states grant entitlement; every downstream
surface (resolver, admin, guides) derives from it rather than
re-deriving from raw `.status`.
Composes `active?/1`, `paused?/1`, and `canceled?/1`, so it inherits
their edge-case handling: a `cancel_at_period_end` paid-through row is
`:active` and therefore entitling, while a `status: :active` row with a
non-nil `pause_collection` (paused) or a non-nil `ended_at` (terminated)
is correctly excluded.
See `guides/lifecycle_semantics.md` for the canonical truth table.
"""
@spec entitling?(%__MODULE__{} | map()) :: boolean()
def entitling?(sub), do: active?(sub) and not paused?(sub) and not canceled?(sub)
@doc """
True if the subscription is in the narrow `:past_due` retry window
where the dunning sweeper is allowed to ask the processor facade
to move it to a terminal action.
Strictly `:past_due` — does NOT include `:unpaid`. An `:unpaid`
subscription has already reached its terminal state (whether via
Stripe-native termination or a prior sweep) and must not be swept
again.
"""
@spec dunning_sweepable?(%__MODULE__{} | map()) :: boolean()
def dunning_sweepable?(%__MODULE__{status: :past_due}), do: true
def dunning_sweepable?(%{status: :past_due}), do: true
def dunning_sweepable?(_), do: false
@doc """
Returns the dunning-terminal status atom (`:unpaid` or `:canceled`)
if the subscription has reached a dunning-exhaustion state. Returns
`nil` otherwise.
Used by the `customer.subscription.updated` webhook reducer to
detect terminal transitions without raw `.status` access.
"""
@spec dunning_exhausted_status(%__MODULE__{} | map()) :: :unpaid | :canceled | nil
def dunning_exhausted_status(%__MODULE__{status: :unpaid}), do: :unpaid
def dunning_exhausted_status(%__MODULE__{status: :canceled}), do: :canceled
def dunning_exhausted_status(%{status: :unpaid}), do: :unpaid
def dunning_exhausted_status(%{status: :canceled}), do: :canceled
def dunning_exhausted_status(_), do: nil
@doc """
True if a dunning campaign is currently active for the subscription.
The campaign anchor `dunning_campaign_started_at` (D-08) is the campaign
identity and the first-transition edge signal. A campaign is active iff the
anchor is a non-nil `DateTime` — set once on the first `nil → past_due`
transition by the D-09 atomic `update_all` elector and cleared on recovery
via `force_status_changeset/2` (D-12).
"""
@spec dunning_campaign_active?(%__MODULE__{} | map()) :: boolean()
def dunning_campaign_active?(%__MODULE__{dunning_campaign_started_at: %DateTime{}}), do: true
def dunning_campaign_active?(%{dunning_campaign_started_at: %DateTime{}}), do: true
def dunning_campaign_active?(_), do: false
@doc """
Extracts a pre-hydrated PaymentIntent from `data.latest_invoice.payment_intent`,
used by `subscribe/3` to surface SCA/3DS action-required to the caller.
"""
@spec pending_intent(%__MODULE__{} | map()) :: map() | nil
def pending_intent(%__MODULE__{data: data}) when is_map(data) do
# Dual-key lookup: the Fake adapter returns atom-keyed maps,
# the Stripe adapter returns string-keyed maps. Normalize here
# rather than forcing callers to know which shape they have.
fetch_key(data, [:latest_invoice, "latest_invoice"])
|> case do
%{} = inv -> fetch_key(inv, [:payment_intent, "payment_intent"])
_ -> nil
end
end
def pending_intent(_), do: nil
defp fetch_key(map, keys) when is_map(map) do
Enum.find_value(keys, fn k -> Map.get(map, k) end)
end
end