Packages

The official Elixir SDK for the Nomba One subscription-billing API — recurring billing for Nigeria over card, direct debit, and bank transfer, with dunning that recovers and a ledger that never loses a kobo.

Current section

Files

Jump to
nombaone lib nombaone subscriptions.ex
Raw

lib/nombaone/subscriptions.ex

defmodule Nombaone.Subscriptions do
@moduledoc """
Subscriptions — the core object. Create one against a customer and a price;
the engine handles cycles, invoices, retries, and recovery.
Scheduled (next-cycle) changes live under `Nombaone.Subscriptions.Schedule`;
read-only recovery state under `Nombaone.Subscriptions.Dunning`.
{:ok, subscription} =
Nombaone.Subscriptions.create(client, %{
customer_id: customer.id,
price_id: price.id,
payment_method_id: method.id
})
subscription.status # => "active"
"""
use Nombaone.Resource
alias Nombaone.{Discount, DomainEvent, PaymentMethod, Subscription, UpcomingInvoice}
@doc """
Create a subscription. This can move money (the first charge), so the SDK
sends an `Idempotency-Key` automatically and reuses it across its own retries.
A payment method is required for `charge_automatically` unless `trial_days > 0`
(the first charge is deferred to trial end).
Common errors: `422 CLIENT_VALIDATION_FAILED` (e.g. a missing payment method
without a trial), `409 SUBSCRIPTION_PAYMENT_METHOD_REQUIRED`.
## Example
{:ok, subscription} =
Nombaone.Subscriptions.create(client, %{
customer_id: customer.id,
price_id: price.id,
payment_method_id: method.id
})
"""
@spec create(Nombaone.Client.t(), map(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def create(client, params, opts \\ []) do
API.request(
client,
%{method: :post, path: "/subscriptions", body: params, options: opts},
Subscription
)
end
@doc "Raising variant of `create/3`."
@spec create!(Nombaone.Client.t(), map(), keyword()) :: Subscription.t()
def create!(client, params, opts \\ []), do: unwrap!(create(client, params, opts))
@doc "Retrieve a subscription by id. Common errors: `404 SUBSCRIPTION_NOT_FOUND`."
@spec retrieve(Nombaone.Client.t(), String.t(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def retrieve(client, id, opts \\ []) do
API.request(
client,
%{method: :get, path: "/subscriptions/#{encode_segment(id)}", options: opts},
Subscription
)
end
@doc "Raising variant of `retrieve/3`."
@spec retrieve!(Nombaone.Client.t(), String.t(), keyword()) :: Subscription.t()
def retrieve!(client, id, opts \\ []), do: unwrap!(retrieve(client, id, opts))
@doc """
Edit metadata or the default payment method (`:default_payment_method_id`,
`:metadata` only). For a price/quantity/interval change (which prorates), use
`change/4`.
"""
@spec update(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def update(client, id, params, opts \\ []) do
API.request(
client,
%{
method: :patch,
path: "/subscriptions/#{encode_segment(id)}",
body: params,
options: opts
},
Subscription
)
end
@doc "Raising variant of `update/4`."
@spec update!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Subscription.t()
def update!(client, id, params, opts \\ []), do: unwrap!(update(client, id, params, opts))
@doc "List subscriptions, newest first. Optional filters: `:customer_id`, `:status`, `:limit`, `:cursor`."
@spec list(Nombaone.Client.t(), map(), keyword()) ::
{:ok, Nombaone.Page.t()} | {:error, Nombaone.Error.t()}
def list(client, params \\ %{}, opts \\ []) do
API.list(
client,
%{method: :get, path: "/subscriptions", query: params, options: opts},
Subscription
)
end
@doc "Raising variant of `list/3`."
@spec list!(Nombaone.Client.t(), map(), keyword()) :: Nombaone.Page.t()
def list!(client, params \\ %{}, opts \\ []), do: unwrap!(list(client, params, opts))
@doc "The subscription's audit trail of domain events, newest first."
@spec list_events(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Nombaone.Page.t()} | {:error, Nombaone.Error.t()}
def list_events(client, id, params \\ %{}, opts \\ []) do
API.list(
client,
%{
method: :get,
path: "/subscriptions/#{encode_segment(id)}/events",
query: params,
options: opts
},
DomainEvent
)
end
@doc "Raising variant of `list_events/4`."
@spec list_events!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Nombaone.Page.t()
def list_events!(client, id, params \\ %{}, opts \\ []),
do: unwrap!(list_events(client, id, params, opts))
@doc """
Pause billing. The subscription keeps its place in the cycle and resumes
cleanly. Optional `:max_days` auto-resumes after that many days.
Common errors: `409 SUBSCRIPTION_ILLEGAL_TRANSITION`.
"""
@spec pause(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def pause(client, id, params \\ %{}, opts \\ []) do
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(id)}/pause",
body: params,
options: opts
},
Subscription
)
end
@doc "Raising variant of `pause/4`."
@spec pause!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Subscription.t()
def pause!(client, id, params \\ %{}, opts \\ []), do: unwrap!(pause(client, id, params, opts))
@doc "Resume a paused subscription."
@spec resume(Nombaone.Client.t(), String.t(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def resume(client, id, opts \\ []) do
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(id)}/resume",
body: %{},
options: opts
},
Subscription
)
end
@doc "Raising variant of `resume/3`."
@spec resume!(Nombaone.Client.t(), String.t(), keyword()) :: Subscription.t()
def resume!(client, id, opts \\ []), do: unwrap!(resume(client, id, opts))
@doc """
Cancel a subscription — immediately (default), or `mode: "at_period_end"` to
keep access until the cycle closes. Optional `:comment`.
## Example
{:ok, subscription} = Nombaone.Subscriptions.cancel(client, id, %{mode: "at_period_end"})
"""
@spec cancel(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def cancel(client, id, params \\ %{}, opts \\ []) do
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(id)}/cancel",
body: params,
options: opts
},
Subscription
)
end
@doc "Raising variant of `cancel/4`."
@spec cancel!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Subscription.t()
def cancel!(client, id, params \\ %{}, opts \\ []),
do: unwrap!(cancel(client, id, params, opts))
@doc """
Start a fresh subscription for a canceled one's customer, reusing the old
price/payment method unless overridden (`:price_id`, `:payment_method_id`).
The subscription must be in a terminal state.
Common errors: `409 SUBSCRIPTION_NOT_TERMINAL`.
"""
@spec resubscribe(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def resubscribe(client, id, params \\ %{}, opts \\ []) do
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(id)}/resubscribe",
body: params,
options: opts
},
Subscription
)
end
@doc "Raising variant of `resubscribe/4`."
@spec resubscribe!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Subscription.t()
def resubscribe!(client, id, params \\ %{}, opts \\ []),
do: unwrap!(resubscribe(client, id, params, opts))
@doc """
Change price or quantity mid-cycle, prorating by default (at least one of
`:price_id`, `:quantity`, `:interval_switch`). Switching the billing interval
mid-cycle is unsupported (`PRORATION_INTERVAL_SWITCH_UNSUPPORTED`) — queue it
with `Nombaone.Subscriptions.Schedule.create/4` instead.
## Example
# Upgrade, prorated on the next invoice:
{:ok, subscription} = Nombaone.Subscriptions.change(client, id, %{price_id: bigger_price.id})
"""
@spec change(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Subscription.t()} | {:error, Nombaone.Error.t()}
def change(client, id, params, opts \\ []) do
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(id)}/change",
body: params,
options: opts
},
Subscription
)
end
@doc "Raising variant of `change/4`."
@spec change!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Subscription.t()
def change!(client, id, params, opts \\ []), do: unwrap!(change(client, id, params, opts))
@doc """
Swap the payment method that bills this subscription — the card-update path
during dunning. Provide exactly one of `:payment_method_reference` (an
already-captured method) or `:checkout_token` (a fresh hosted-checkout token).
Returns the attached `Nombaone.PaymentMethod`.
"""
@spec update_payment_method(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, PaymentMethod.t()} | {:error, Nombaone.Error.t()}
def update_payment_method(client, id, params, opts \\ []) do
# The deployed endpoint returns the attached PaymentMethod (not the
# Subscription that the OpenAPI spec declares) — cast to the runtime truth.
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(id)}/payment-method",
body: params,
options: opts
},
PaymentMethod
)
end
@doc "Raising variant of `update_payment_method/4`."
@spec update_payment_method!(Nombaone.Client.t(), String.t(), map(), keyword()) ::
PaymentMethod.t()
def update_payment_method!(client, id, params, opts \\ []),
do: unwrap!(update_payment_method(client, id, params, opts))
@doc "Preview the next invoice without charging or storing anything."
@spec retrieve_upcoming_invoice(Nombaone.Client.t(), String.t(), keyword()) ::
{:ok, UpcomingInvoice.t()} | {:error, Nombaone.Error.t()}
def retrieve_upcoming_invoice(client, id, opts \\ []) do
API.request(
client,
%{
method: :get,
path: "/subscriptions/#{encode_segment(id)}/upcoming-invoice",
options: opts
},
UpcomingInvoice
)
end
@doc "Raising variant of `retrieve_upcoming_invoice/3`."
@spec retrieve_upcoming_invoice!(Nombaone.Client.t(), String.t(), keyword()) ::
UpcomingInvoice.t()
def retrieve_upcoming_invoice!(client, id, opts \\ []),
do: unwrap!(retrieve_upcoming_invoice(client, id, opts))
@doc "Apply a coupon to this subscription only. `:coupon` is a coupon id or its code."
@spec apply_discount(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Discount.t()} | {:error, Nombaone.Error.t()}
def apply_discount(client, id, params, opts \\ []) do
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(id)}/discount",
body: params,
options: opts
},
Discount
)
end
@doc "Raising variant of `apply_discount/4`."
@spec apply_discount!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Discount.t()
def apply_discount!(client, id, params, opts \\ []),
do: unwrap!(apply_discount(client, id, params, opts))
@doc "Remove the subscription's active discount. Returns the ended discount."
@spec remove_discount(Nombaone.Client.t(), String.t(), keyword()) ::
{:ok, Discount.t()} | {:error, Nombaone.Error.t()}
def remove_discount(client, id, opts \\ []) do
API.request(
client,
%{method: :delete, path: "/subscriptions/#{encode_segment(id)}/discount", options: opts},
Discount
)
end
@doc "Raising variant of `remove_discount/3`."
@spec remove_discount!(Nombaone.Client.t(), String.t(), keyword()) :: Discount.t()
def remove_discount!(client, id, opts \\ []), do: unwrap!(remove_discount(client, id, opts))
end
defmodule Nombaone.Subscriptions.Schedule do
@moduledoc """
Scheduled (next-cycle) changes queued against a subscription — the safe way
to switch billing intervals (mid-cycle interval proration is unsupported).
"""
use Nombaone.Resource
alias Nombaone.SubscriptionSchedule
@doc """
Queue a change for the next cycle boundary. `:price_id` is the price to switch
to; optional `:quantity`.
Common errors: `409 SUBSCRIPTION_SCHEDULE_CONFLICT`.
"""
@spec create(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, SubscriptionSchedule.t()} | {:error, Nombaone.Error.t()}
def create(client, subscription_id, params, opts \\ []) do
API.request(
client,
%{
method: :post,
path: "/subscriptions/#{encode_segment(subscription_id)}/schedule",
body: params,
options: opts
},
SubscriptionSchedule
)
end
@doc "Raising variant of `create/4`."
@spec create!(Nombaone.Client.t(), String.t(), map(), keyword()) :: SubscriptionSchedule.t()
def create!(client, subscription_id, params, opts \\ []),
do: unwrap!(create(client, subscription_id, params, opts))
@doc "Retrieve the subscription's schedule. Common errors: `404 SUBSCRIPTION_SCHEDULE_NOT_FOUND`."
@spec retrieve(Nombaone.Client.t(), String.t(), keyword()) ::
{:ok, SubscriptionSchedule.t()} | {:error, Nombaone.Error.t()}
def retrieve(client, subscription_id, opts \\ []) do
API.request(
client,
%{
method: :get,
path: "/subscriptions/#{encode_segment(subscription_id)}/schedule",
options: opts
},
SubscriptionSchedule
)
end
@doc "Raising variant of `retrieve/3`."
@spec retrieve!(Nombaone.Client.t(), String.t(), keyword()) :: SubscriptionSchedule.t()
def retrieve!(client, subscription_id, opts \\ []),
do: unwrap!(retrieve(client, subscription_id, opts))
@doc "Cancel the pending schedule before it applies."
@spec release(Nombaone.Client.t(), String.t(), keyword()) ::
{:ok, SubscriptionSchedule.t()} | {:error, Nombaone.Error.t()}
def release(client, subscription_id, opts \\ []) do
API.request(
client,
%{
method: :delete,
path: "/subscriptions/#{encode_segment(subscription_id)}/schedule",
options: opts
},
SubscriptionSchedule
)
end
@doc "Raising variant of `release/3`."
@spec release!(Nombaone.Client.t(), String.t(), keyword()) :: SubscriptionSchedule.t()
def release!(client, subscription_id, opts \\ []),
do: unwrap!(release(client, subscription_id, opts))
end
defmodule Nombaone.Subscriptions.Dunning do
@moduledoc """
Read-only view into a subscription's recovery state. `past_due` usually means
"not yet", not "no" — check `grace_access_until` before cutting access.
"""
use Nombaone.Resource
alias Nombaone.{DunningAttempt, DunningState}
@doc "Where the subscription stands in dunning."
@spec retrieve(Nombaone.Client.t(), String.t(), keyword()) ::
{:ok, DunningState.t()} | {:error, Nombaone.Error.t()}
def retrieve(client, subscription_id, opts \\ []) do
API.request(
client,
%{
method: :get,
path: "/subscriptions/#{encode_segment(subscription_id)}/dunning",
options: opts
},
DunningState
)
end
@doc "Raising variant of `retrieve/3`."
@spec retrieve!(Nombaone.Client.t(), String.t(), keyword()) :: DunningState.t()
def retrieve!(client, subscription_id, opts \\ []),
do: unwrap!(retrieve(client, subscription_id, opts))
@doc "List every recovery attempt, newest first."
@spec list_attempts(Nombaone.Client.t(), String.t(), map(), keyword()) ::
{:ok, Nombaone.Page.t()} | {:error, Nombaone.Error.t()}
def list_attempts(client, subscription_id, params \\ %{}, opts \\ []) do
API.list(
client,
%{
method: :get,
path: "/subscriptions/#{encode_segment(subscription_id)}/dunning/attempts",
query: params,
options: opts
},
DunningAttempt
)
end
@doc "Raising variant of `list_attempts/4`."
@spec list_attempts!(Nombaone.Client.t(), String.t(), map(), keyword()) :: Nombaone.Page.t()
def list_attempts!(client, subscription_id, params \\ %{}, opts \\ []),
do: unwrap!(list_attempts(client, subscription_id, params, opts))
end