# `Accrue.Billing.Subscription`
[🔗](https://github.com/szTheory/accrue/blob/accrue-v1.5.1/lib/accrue/billing/subscription.ex#L1)

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`.

# `t`

```elixir
@type t() :: %Accrue.Billing.Subscription{
  __meta__: term(),
  automatic_tax: term(),
  automatic_tax_disabled_reason: term(),
  automatic_tax_status: term(),
  cancel_at: term(),
  cancel_at_period_end: term(),
  canceled_at: term(),
  current_period_end: term(),
  current_period_start: term(),
  customer: term(),
  customer_id: term(),
  data: term(),
  discount_id: term(),
  dunning_campaign_started_at: term(),
  dunning_sweep_attempted_at: term(),
  ended_at: term(),
  id: term(),
  inserted_at: term(),
  last_stripe_event_id: term(),
  last_stripe_event_ts: term(),
  lock_version: term(),
  metadata: term(),
  past_due_since: term(),
  pause_behavior: term(),
  pause_collection: term(),
  paused_at: term(),
  processor: term(),
  processor_id: term(),
  status: term(),
  subscription_items: term(),
  trial_end: term(),
  trial_start: term(),
  updated_at: term()
}
```

# `active?`

```elixir
@spec active?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

True if the subscription counts as "active" for entitlement purposes.

Includes `:trialing` — a customer in a trial period has full access.

# `canceled?`

```elixir
@spec canceled?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

True if the subscription has terminated.

`:canceled`, `:incomplete_expired`, or any row with a non-nil `ended_at`.

# `canceling?`

```elixir
@spec canceling?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

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).

# `changeset`

```elixir
@spec changeset(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | Ecto.Changeset.t(),
  map()
) :: Ecto.Changeset.t()
```

Builds a changeset for creating or updating a Subscription.

# `dunning_campaign_active?`

```elixir
@spec dunning_campaign_active?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

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).

# `dunning_exhausted_status`

```elixir
@spec dunning_exhausted_status(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: :unpaid | :canceled | nil
```

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.

# `dunning_sweepable?`

```elixir
@spec dunning_sweepable?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

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.

# `entitling?`

```elixir
@spec entitling?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

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.

# `force_status_changeset`

```elixir
@spec force_status_changeset(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | Ecto.Changeset.t(),
  map()
) :: Ecto.Changeset.t()
```

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.

# `past_due?`

```elixir
@spec past_due?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

True if the subscription is past due or unpaid (dunning territory).

# `paused?`

```elixir
@spec paused?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

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.

# `pending_intent`

```elixir
@spec pending_intent(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: map() | nil
```

Extracts a pre-hydrated PaymentIntent from `data.latest_invoice.payment_intent`,
used by `subscribe/3` to surface SCA/3DS action-required to the caller.

# `statuses`

```elixir
@spec statuses() :: [atom()]
```

Canonical list of subscription statuses (Stripe's 8 values).

# `trialing?`

```elixir
@spec trialing?(
  %Accrue.Billing.Subscription{
    __meta__: term(),
    automatic_tax: term(),
    automatic_tax_disabled_reason: term(),
    automatic_tax_status: term(),
    cancel_at: term(),
    cancel_at_period_end: term(),
    canceled_at: term(),
    current_period_end: term(),
    current_period_start: term(),
    customer: term(),
    customer_id: term(),
    data: term(),
    discount_id: term(),
    dunning_campaign_started_at: term(),
    dunning_sweep_attempted_at: term(),
    ended_at: term(),
    id: term(),
    inserted_at: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    past_due_since: term(),
    pause_behavior: term(),
    pause_collection: term(),
    paused_at: term(),
    processor: term(),
    processor_id: term(),
    status: term(),
    subscription_items: term(),
    trial_end: term(),
    trial_start: term(),
    updated_at: term()
  }
  | map()
) :: boolean()
```

True if the subscription is currently in a trial.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
