View Source Money.Subscription (Money v5.18.0)

Provides functions to create, upgrade and downgrade subscriptions from one plan to another.

Since moving from one plan to another may require prorating the payment stream at the point of transition, this module is introduced to provide a single point of calculation of the proration in order to give clear focus to the issues of calculating the carry-over amount or the carry-over period at the point of plan change.

Defining a subscription

A subscription records this current state and history of all plans assigned over time to a subscriber. The definition is deliberately minimal to simplify integration into applications that have a specific implementation of a subscription.

A new subscription is created with Money.Subscription.new/3 which has the following attributes:

  • plan which defines the initial plan for the subscription. This option is required.

  • effective_date which determines the effective date of the inital plan. This option is required.

  • options which include :created_at and :id with which a subscription may be annotated

Changing a subscription plan

Changing a subscription plan requires the following information be provided:

  • A Subscription or the definition of the current plan

  • The definition of the new plan

  • The strategy for changing the plan which is either:

    • to have the effective date of the new plan be after the current interval of the current plan

    • To change the plan immediately in which case there will be a credit on the current plan which needs to be applied to the new plan.

See Money.Subscription.change_plan/3

When the new plan is effective at the end of the current billing period

The first strategy simply finishes the current billing period before the new plan is introduced and therefore no proration is required. This is the default strategy.

When the new plan is effective immediately

If the new plan is to be effective immediately then any credit balance remaining on the old plan needs to be applied to the new plan. There are two options of applying the credit:

  1. Reduce the billing amount of the first period of the new plan be the amount of the credit left on the old plan. This means that the billing amount for the first period of the new plan will be different (less) than the billing amount for subsequent periods on the new plan.

  2. Extend the first period of the new plan by the interval amount that can be funded by the credit amount left on the old plan. In the situation where the credit amount does not fully fund an integral interval the additional interval can be truncated or rounded up to the next integral period.

Plan definition

This module, and Money in general, does not provide a full billing or subscription solution - its focus is to support a reliable means of calcuating the accounting outcome of a plan change only. Therefore the plan definition required by Money.Subscription can be any Map.t that includes the following fields:

  • interval which defines the time interval for a plan. The value can be one of day, week, month or year.

  • interval_count which defines the number of intervals for the current plan interval. This must be a positive integer.

  • price which is a Money.t representing the price of the plan to be paid each interval count.

Billing in advance

This module calculates all subscription changes on the basis that billing is done in advance. This primarily affects the calculation of plan credit when a plan changes. The assumption is that the period from the start of the current interval to the point of change has been consumed and therefore the credit is based upon that period of the plan that has not yet been consumed.

If the calculation was based upon "payment in arrears" then the credit would actually be a debit since the part of the current period consumed has not yet been paid.

Summary

Types

An id that uniquely identifies a subscription

t()

A Money.Subscription type

Functions

A struct defining a subscription

Cancel a subscription's pending plan.

Change plan from the current plan to a new plan.

Change plan from the current plan to a new plan.

Returns the first date of the current interval of a plan.

Retrieve the plan that is currently in affect.

Returns the start date of the current plan.

Returns number of days remaining in a plan interval.

Returns the latest plan for a subscription.

Creates a new subscription.

Creates a new subscription or raises an exception.

Returns the next interval start date for a plan.

Returns number of days in a plan interval.

Returns a boolean indicating if there is a pending plan.

Types

@type id() :: term()

An id that uniquely identifies a subscription

@type t() :: %Money.Subscription{
  created_at: DateTime.t(),
  id: id(),
  plans: [{Money.Subscription.Change.t(), Money.Subscription.Plan.t()}]
}

A Money.Subscription type

Functions

Link to this function

%Money.Subscription{}

View Source (struct)

A struct defining a subscription

  • :id any term that uniquely identifies this subscription

  • :plans is a list of {change, plan} tuples that record the history of plans assigned to this subscription

  • :created_at records the DateTime.t when the subscription was created

Link to this function

cancel_pending_plan(subscription, options \\ [])

View Source
@spec cancel_pending_plan(t(), Keyword.t()) :: t()

Cancel a subscription's pending plan.

A pending plan arise when a a Subscription.change_plan/3 has been executed but the effective date is in the future. Only one plan may be pending at any one time so that if Subscription.change_plan/3 is attemtped a second time an error tuple will be returned.

Subscription.cancel_pending_plan/2 can be used to roll back the pending plan change.

Arguments

  • :subscription is any Money.Subscription.t

  • :options is a Keyword.t

Options

  • :today is a Date.t that represents today. The default is Date.utc_today

Returns

  • An updated Money.Subscription.t which may or may not have had a pending plan. If it did have a pending plan that plan is deleted. If there was no pending plan then the subscription is returned unchanged.
Link to this function

change_plan(subscription_or_plan, new_plan, options \\ [])

View Source
@spec change_plan(
  subscription_or_plan :: t() | Money.Subscription.Plan.t(),
  new_plan :: Money.Subscription.Plan.t(),
  options :: Keyword.t()
) ::
  {:ok, Money.Subscription.Change.t() | t()} | {:error, {module(), String.t()}}

Change plan from the current plan to a new plan.

Arguments

  • subscription_or_plan is either a Money.Subscription.t or Money.Subscription.Plan.t or a map with the same fields

  • new_plan is a Money.Subscription.Plan.t or a map with at least the fields interval, interval_count and price

  • current_interval_started is a Date.t or other map with the fields year, month, day and calendar

  • options is a keyword list of options the define how the change is to be made

Options

  • :effective defines when the new plan comes into effect. The values are :immediately, a Date.t or :next_period. The default is :next_period. Note that the date applied in the case of :immediately is the date returned by Date.utc_today.

  • :prorate which determines how to prorate the current plan into the new plan. The options are :price which will reduce the price of the first period of the new plan by the credit amount left on the old plan (this is the default). Or :period in which case the first period of the new plan is extended by the interval amount of the new plan that the credit on the old plan will fund.

  • :round determines whether when prorating the :period it is truncated or rounded up to the next nearest full interval_count. Valid values are :down, :half_up, :half_even, :ceiling, :floor, :half_down, :up. The default is :up.

  • :first_interval_started determines the anchor day for monthly billing. For example if a monthly plan starts on January 31st then the next period will start on February 28th (or 29th). The period following that should, however, be March 31st. If subscription_or_plan is a Money.Subscription.t then the :first_interval_started is automatically populated from the subscription. If :first_interval_started is nil then the date defined by :effective is used.

Returns

A Money.Subscription.Change.t with the following elements:

  • :first_interval_starts which is the start date of the first interval for the new plan

  • :first_billing_amount is the amount to be billed, net of any credit, at the :first_interval_starts

  • :next_interval_starts is the start date of the next interval after the first intervalincluding any credit_days_applied

  • :credit_amount is the amount of unconsumed credit of the current plan

  • :credit_amount_applied is the amount of credit applied to the new plan. If the :prorate option is :price (the default) then :first_billing_amount is the plan :price reduced by the :credit_amount_applied. If the :prorate option is :period then the :first_billing_amount is the plan price and the :next_interval_date is extended by the :credit_days_applied instead.

  • :credit_days_applied is the number of days credit applied to the first interval by adding days to the :first_interval_starts date.

  • :credit_period_ends is the date on which any applied credit is consumed or nil

  • :carry_forward is any amount of credit carried forward to a subsequent period. If non-zero, this amount is a negative Money.t. It is non-zero when the credit amount for the current plan is greater than the :price of the new plan. In this case the :first_billing_amount is zero.

Returns

  • {:ok, updated_subscription} or

  • {:error, {exception, message}}

Examples

# Change at end of the current period so no proration
iex> current = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 1)
iex> new = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 3)
iex> Money.Subscription.change_plan current, new, current_interval_started: ~D[2018-01-01]
{:ok, %Money.Subscription.Change{
  carry_forward: Money.zero(:USD),
  credit_amount: Money.zero(:USD),
  credit_amount_applied: Money.zero(:USD),
  credit_days_applied: 0,
  credit_period_ends: nil,
  next_interval_starts: ~D[2018-05-01],
  first_billing_amount: Money.new(:USD, 10),
  first_interval_starts: ~D[2018-02-01]
}}

# Change during the current plan generates a credit amount
iex> current = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 1)
iex> new = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 3)
iex> Money.Subscription.change_plan current, new, current_interval_started: ~D[2018-01-01], effective: ~D[2018-01-15]
{:ok, %Money.Subscription.Change{
  carry_forward: Money.zero(:USD),
  credit_amount: Money.new(:USD, "5.49"),
  credit_amount_applied: Money.new(:USD, "5.49"),
  credit_days_applied: 0,
  credit_period_ends: nil,
  next_interval_starts: ~D[2018-04-15],
  first_billing_amount: Money.new(:USD, "4.51"),
  first_interval_starts: ~D[2018-01-15]
}}

# Change during the current plan generates a credit period
iex> current = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 1)
iex> new = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 3)
iex> Money.Subscription.change_plan current, new, current_interval_started: ~D[2018-01-01], effective: ~D[2018-01-15], prorate: :period
{:ok, %Money.Subscription.Change{
  carry_forward: Money.zero(:USD),
  credit_amount: Money.new(:USD, "5.49"),
  credit_amount_applied: Money.zero(:USD),
  credit_days_applied: 50,
  credit_period_ends: ~D[2018-03-05],
  next_interval_starts: ~D[2018-06-04],
  first_billing_amount: Money.new(:USD, 10),
  first_interval_starts: ~D[2018-01-15]
}}
Link to this function

change_plan!(subscription_or_plan, new_plan, options \\ [])

View Source
@spec change_plan!(
  subscription_or_plan :: t() | Money.Subscription.Plan.t(),
  new_plan :: Money.Subscription.Plan.t(),
  options :: Keyword.t()
) :: Money.Subscription.Change.t() | no_return()

Change plan from the current plan to a new plan.

Retuns the plan or raises an exception on error.

See Money.Subscription.change_plan/3 for the description of arguments, options and return.

Link to this function

current_interval_start_date(subscription_or_changeset, options \\ [])

View Source
@spec current_interval_start_date(
  t() | {Money.Subscription.Change.t(), Money.Subscription.Plan.t()} | map(),
  Keyword.t()
) :: Date.t()

Returns the first date of the current interval of a plan.

Arguments

  • :subscription_or_changeset is anyMoney.Subscription.t or a {Change.t, Plan.t} tuple

  • :options is a keyword list of options

Options

  • :today is a Date.t that represents today. The default is Date.utc_today

Returns

  • The Date.t that is the first date of the current interval
Link to this function

current_plan(subscription, options \\ [])

View Source

Retrieve the plan that is currently in affect.

The plan in affect is not necessarily the first plan in the list. We may have upgraded plans to be in affect at some later time.

Arguments

  • subscription is a Money.Subscription.t or any map that provides the field :plans

Returns

  • The Money.Subscription.Plan.t that is the plan currently in affect or nil
Link to this function

current_plan_start_date(subscription)

View Source
@spec current_plan_start_date(t()) :: Date.t() | nil

Returns the start date of the current plan.

Arguments

  • subscription is a Money.Subscription.t or any map that provides the field :plans

Returns

  • The start Date.t of the current plan
Link to this function

days_remaining(plan, current_interval_started, effective_date, options \\ [])

View Source
@spec days_remaining(Money.Subscription.Plan.t(), Date.t(), Date.t(), Keyword.t()) ::
  integer()

Returns number of days remaining in a plan interval.

Arguments

  • plan is any Money.Subscription.Plan.t

  • current_interval_started is a Date.t

  • effective_date is a Date.t after the current_interval_started and before the end of the plan_days

Returns

The number of days remaining in a plan interval

Examples

iex> plan = Money.Subscription.Plan.new! Money.new!(:USD, 100), :month, 1
iex> Money.Subscription.days_remaining plan, ~D[2018-01-01], ~D[2018-01-02]
30
iex> Money.Subscription.days_remaining plan, ~D[2018-02-01], ~D[2018-02-02]
27
@spec latest_plan(t() | map()) ::
  {Money.Subscription.Change.t(), Money.Subscription.Plan.t()}

Returns the latest plan for a subscription.

The latest plan may not be in affect since its start date may be in the future.

Arguments

  • subscription is a Money.Subscription.t or any map that provides the field :plans

Returns

  • The Money.Subscription.Plan.t that is the most recent plan - whether or not it is the currently active plan.
Link to this function

new(plan, effective_date, options \\ [])

View Source
@spec new(
  plan :: Money.Subscription.Plan.t(),
  effective_date :: Date.t(),
  Keyword.t()
) ::
  {:ok, t()} | {:error, {module(), String.t()}}

Creates a new subscription.

Arguments

  • plan is any Money.Subscription.Plan.t the defines the initial plan

  • effective_date is a Date.t that represents the effective date of the initial plan. This defines the start of the first interval

  • options is a keyword list of options

Options

  • :id is any term that an application can use to uniquely identify this subscription. It is not used in any function in this module.

  • :created_at is a DateTime.t that records the timestamp when the subscription was created. The default is DateTime.utc_now/0

Returns

  • {:ok, Money.Subscription.t} or

  • {:error, {exception, message}}

Link to this function

new!(plan, effective_date, options \\ [])

View Source
@spec new!(
  plan :: Money.Subscription.Plan.t(),
  effective_date :: Date.t(),
  Keyword.t()
) ::
  t() | no_return()

Creates a new subscription or raises an exception.

Arguments

  • plan is any Money.Subscription.Plan.t the defines the initial plan

  • effective_date is a Date.t that represents the effective date of the initial plan. This defines the start of the first interval

  • :options is a keyword list of options

Options

  • :id is any term that an application can use to uniquely identify this subscription. It is not used in any function in this module.

  • :created_at is a DateTime.t that records the timestamp when the subscription was created. The default is DateTime.utc_now/0

Returns

  • A Money.Subscription.t or

  • raises an exception

Link to this function

next_interval_starts(plan, current_interval_started, options \\ [])

View Source
@spec next_interval_starts(Money.Subscription.Plan.t(), Date.t(), Keyword.t()) ::
  Date.t()

Returns the next interval start date for a plan.

Arguments

  • plan is any Money.Subscription.Plan.t

  • :current_interval_started is the Date.t that represents the start of the current interval

Returns

The next interval start date as a Date.t.

Example

iex> plan = Money.Subscription.Plan.new!(Money.new!(:USD, 100), :month)
iex> Money.Subscription.next_interval_starts(plan, ~D[2018-03-01])
~D[2018-04-01]

iex> plan = Money.Subscription.Plan.new!(Money.new!(:USD, 100), :day, 30)
iex> Money.Subscription.next_interval_starts(plan, ~D[2018-02-01])
~D[2018-03-03]
Link to this function

plan_days(plan, current_interval_started, options \\ [])

View Source
@spec plan_days(Money.Subscription.Plan.t(), Date.t(), Keyword.t()) :: integer()

Returns number of days in a plan interval.

Arguments

  • plan is any Money.Subscription.Plan.t

  • current_interval_started is any Date.t

Returns

The number of days in a plan interval.

Examples

iex> plan = Money.Subscription.Plan.new! Money.new!(:USD, 100), :month, 1
iex> Money.Subscription.plan_days plan, ~D[2018-01-01]
31
iex> Money.Subscription.plan_days plan, ~D[2018-02-01]
28
iex> Money.Subscription.plan_days plan, ~D[2018-04-01]
30
Link to this function

plan_pending?(map, options \\ [])

View Source
@spec plan_pending?(t(), Keyword.t()) :: boolean()

Returns a boolean indicating if there is a pending plan.

A pending plan is one where the subscription has changed plans but the plan is not yet in effect. There can only be one pending plan.

Arguments

  • :subscription is any Money.Subscription.t

  • :options is a keyword list of options

Options

  • :today is a Date.t that represents the effective date used to determine is there is a pending plan. The default is Date.utc_today/1.

Returns

  • Either true or false