Accrue.Billing.Subscription (accrue v1.4.0)

Copy Markdown View Source

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:

For database queries over multiple subscriptions, the matching fragments are in Accrue.Billing.Query.

Summary

Functions

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

True if the subscription has terminated.

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

Builds a changeset for creating or updating a Subscription.

True if a dunning campaign is currently active for the subscription.

Returns the dunning-terminal status atom (:unpaid or :canceled) if the subscription has reached a dunning-exhaustion state. Returns nil otherwise.

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.

True iff the subscription's pure lifecycle grants entitlement.

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.

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

True if the subscription is paused.

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

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

True if the subscription is currently in a trial.

Types

t()

@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()
}

Functions

active?(arg1)

@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?(arg1)

@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?(arg1)

@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(subscription_or_changeset, attrs \\ %{})

@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?(arg1)

@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(arg1)

@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?(arg1)

@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?(sub)

@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(subscription_or_changeset, attrs \\ %{})

@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?(arg1)

@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?(arg1)

@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(arg1)

@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()

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

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

trialing?(arg1)

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