Accrue.Billing (accrue v1.1.2)

Copy Markdown View Source

Primary context module for Accrue billing operations.

All write operations for billable entities live here, following conventional Phoenix context boundaries. Host schemas gain access to these operations via use Accrue.Billable, which injects a convenience customer/1 that delegates here.

Customer lifecycle

  • customer/1 — lazy fetch-or-create. First call auto-creates an accrue_customers row via the configured processor; subsequent calls return the cached row.
  • create_customer/1 — explicit create, always hits the processor.
  • customer!/1 and create_customer!/1 — raising variants following the same {:ok, _} | {:error, _} vs ! naming convention as the rest of Accrue.

All writes use Ecto.Multi to ensure the customer row and the corresponding accrue_events entry are committed atomically.

Summary

Functions

Creates a Customer Billing Portal session for customer through the configured processor.

Bang variant of create_billing_portal_session/2 — returns %Accrue.BillingPortal.Session{} or raises.

Creates a checkout handoff for customer through the configured processor.

Bang variant of create_checkout_session/2 — returns %Accrue.Checkout.Session{} or raises.

Explicitly creates a Customer for the given billable struct.

Raising variant of create_customer/1. Returns the Customer directly or raises on error.

Lazily fetches or creates a Customer for the given billable struct.

Raising variant of customer/1. Returns the Customer directly or raises on error.

Returns locally projected payment methods for customer.

Shallow-merges partial_data into the existing data column.

Official active-subscription-change preview facade for inspecting the next invoice before commit.

Fully replaces the data jsonb column on a billing record.

report_usage/3 records a metered usage event for customer (a %Accrue.Billing.Customer{} or Stripe customer id string) and event_name, persisting through the transactional outbox before invoking the configured processor.

Bang variant of report_usage/3 — returns %Accrue.Billing.MeterEvent{} or raises on error.

Official active-subscription-change facade for replacing the current subscription price with a new price_id.

Updates a processor-backed Customer with the bounded shared contract.

Updates only the local Customer row for host-owned maintenance.

Updates a processor-backed customer's tax location with immediate validation.

Functions

add_item(sub, price_id, opts \\ [])

add_item!(sub, price_id, opts \\ [])

add_payment_method(customer, attrs_or_id, opts \\ [])

add_payment_method!(customer, attrs_or_id, opts \\ [])

apply_promotion_code(sub, code, opts \\ [])

apply_promotion_code!(sub, code, opts \\ [])

attach_payment_method(customer, pm_id_or_opts, opts \\ [])

attach_payment_method!(customer, pm_id_or_opts, opts \\ [])

cancel(sub, opts \\ [])

cancel!(sub, opts \\ [])

cancel_at_period_end(sub, opts \\ [])

cancel_at_period_end!(sub, opts \\ [])

cancel_schedule(sched, opts \\ [])

cancel_schedule!(sched, opts \\ [])

charge(customer, amount_or_opts, opts \\ [])

charge!(customer, amount_or_opts, opts \\ [])

comp_subscription(billable, price_spec, opts \\ [])

comp_subscription!(billable, price_spec, opts \\ [])

create_billing_portal_session(customer, attrs)

@spec create_billing_portal_session(Accrue.Billing.Customer.t(), keyword() | map()) ::
  {:ok, Accrue.BillingPortal.Session.t()} | {:error, term()}

Creates a Customer Billing Portal session for customer through the configured processor.

attrs is a keyword list or map of options aligned with Accrue.BillingPortal.Session.create/1, except :customer (supplied as the first argument): :return_url, :configuration, :flow_data, :locale, :on_behalf_of, :operation_id.

Invalid keys or types raise NimbleOptions.ValidationError.

The portal session URL is a short-lived bearer credential. Do not log raw session structs, processor payloads, or URLs in production telemetry or support tickets. For :configuration, see guides/portal_configuration_checklist.md.

Processor routing: Stripe keeps returning Stripe-hosted billing-portal URLs. Braintree routes this call through the host app's mounted first-party local portal, so the returned url points at the configured Accrue.Portal mount path instead of an upstream Braintree page. If that local portal is not mounted/configured yet, the Braintree adapter preserves the :unsupported_by_gateway fallback from the pre-portal contract.

Emits [:accrue, :billing, :billing_portal, :create] (OpenTelemetry name accrue.billing.billing_portal.create).

create_billing_portal_session!(customer, attrs)

@spec create_billing_portal_session!(Accrue.Billing.Customer.t(), keyword() | map()) ::
  Accrue.BillingPortal.Session.t()

Bang variant of create_billing_portal_session/2 — returns %Accrue.BillingPortal.Session{} or raises.

Raises NimbleOptions.ValidationError when attrs fail validation.

On {:error, reason} from the underlying processor operation, re-raises when reason implements Exception; otherwise raises a generic failure message.

create_checkout_session(customer, attrs)

@spec create_checkout_session(Accrue.Billing.Customer.t(), keyword() | map()) ::
  {:ok, Accrue.Checkout.Session.t()} | {:error, term()}

Creates a checkout handoff for customer through the configured processor.

attrs is a keyword list or map of options aligned with Accrue.Checkout.Session.create/1, except :customer (supplied as the first argument): :mode, :ui_mode, :line_items, :success_url, :cancel_url, :return_url, :metadata, :client_reference_id, :automatic_tax, :operation_id.

Invalid keys or types raise NimbleOptions.ValidationError.

The checkout redirect URL (hosted mode) and client_secret (embedded mode) are bearer credentials. Do not log raw session structs, processor payloads, or URLs in production telemetry or support tickets.

Processor routing: Stripe semantics are unchanged. When the configured processor is Braintree, hosted checkout resolves to the host app's mounted first-party local portal URL rather than an upstream hosted page, while the public :ui_mode contract stays limited to :hosted | :embedded.

Emits [:accrue, :billing, :checkout_session, :create] (OpenTelemetry-style name accrue.billing.checkout_session.create). See Accrue.Checkout.Session for field semantics and the underlying @create_schema.

create_checkout_session!(customer, attrs)

@spec create_checkout_session!(Accrue.Billing.Customer.t(), keyword() | map()) ::
  Accrue.Checkout.Session.t()

Bang variant of create_checkout_session/2 — returns %Accrue.Checkout.Session{} or raises.

Raises NimbleOptions.ValidationError when attrs fail validation.

On {:error, reason} from the underlying CheckoutSession.create/1, re-raises when reason implements Exception; otherwise raises with prefix Accrue.Checkout.Session.create/1 failed:.

create_coupon(params, opts \\ [])

create_coupon!(params, opts \\ [])

create_customer(billable)

@spec create_customer(struct()) ::
  {:ok, Accrue.Billing.Customer.t()} | {:error, term()}

Explicitly creates a Customer for the given billable struct.

Uses Ecto.Multi to atomically:

  1. Create the customer on the processor side (Fake or Stripe)
  2. Insert the accrue_customers row with the processor-assigned ID
  3. Record a "customer.created" event

Returns {:ok, %Customer{}} on success or {:error, reason} on failure. The entire transaction rolls back if any step fails.

Examples

{:ok, customer} = Accrue.Billing.create_customer(user)
customer.processor_id  #=> "cus_fake_00001"

create_customer!(billable)

@spec create_customer!(struct()) :: Accrue.Billing.Customer.t()

Raising variant of create_customer/1. Returns the Customer directly or raises on error.

create_payment_intent(customer, opts \\ [])

create_payment_intent!(customer, opts \\ [])

create_promotion_code(params, opts \\ [])

create_promotion_code!(params, opts \\ [])

create_refund(charge, opts \\ [])

create_refund!(charge, opts \\ [])

create_setup_intent(customer, opts \\ [])

create_setup_intent!(customer, opts \\ [])

customer(billable)

@spec customer(struct()) :: {:ok, Accrue.Billing.Customer.t()} | {:error, term()}

Lazily fetches or creates a Customer for the given billable struct.

If a customer row already exists for the billable's owner_type and owner_id, returns it. Otherwise, creates one via the configured processor and persists it atomically with an event record.

Examples

{:ok, customer} = Accrue.Billing.customer(user)
{:ok, ^customer} = Accrue.Billing.customer(user)  # same row

customer!(billable)

@spec customer!(struct()) :: Accrue.Billing.Customer.t()

Raising variant of customer/1. Returns the Customer directly or raises on error.

delete_payment_method(payment_method, opts \\ [])

delete_payment_method!(payment_method, opts \\ [])

detach_payment_method(payment_method, opts \\ [])

detach_payment_method!(payment_method, opts \\ [])

fetch_invoice_pdf(invoice_or_id)

finalize_invoice(invoice, opts \\ [])

finalize_invoice!(invoice, opts \\ [])

get_discount_mapping(code, opts \\ [])

get_subscription(id, opts \\ [])

get_subscription!(id, opts \\ [])

list_payment_methods(customer, opts \\ [])

Returns locally projected payment methods for customer.

list_payment_methods!(customer, opts \\ [])

Raising variant of list_payment_methods/2.

Delegates to Accrue.Billing.PaymentMethodActions.list_payment_methods!/2.

mark_uncollectible(invoice, opts \\ [])

mark_uncollectible!(invoice, opts \\ [])

patch_data(record, partial_data)

@spec patch_data(Ecto.Schema.t(), map()) :: {:ok, Ecto.Schema.t()} | {:error, term()}

Shallow-merges partial_data into the existing data column.

Used when a partial event carries only a delta. Applies optimistic locking via lock_version.

Examples

{:ok, patched} = Accrue.Billing.patch_data(customer, %{"balance" => 100})

pause(sub, opts \\ [])

pause!(sub, opts \\ [])

pay_invoice(invoice, opts \\ [])

pay_invoice!(invoice, opts \\ [])

preview_upcoming_invoice(sub_or_customer, opts \\ [])

Official active-subscription-change preview facade for inspecting the next invoice before commit.

This is the canonical path where supported for preview-before-commit guidance. See guides/lifecycle_semantics.md for the semantic contract and .planning/processor-support-matrix.md for provider-honest support limits, including Braintree's explicit preview boundary.

preview_upcoming_invoice!(sub_or_customer, opts \\ [])

Raising variant of preview_upcoming_invoice/2.

put_data(record, new_data)

@spec put_data(Ecto.Schema.t(), map()) :: {:ok, Ecto.Schema.t()} | {:error, term()}

Fully replaces the data jsonb column on a billing record.

Used by webhook reconcile paths that receive the whole object (e.g. customer.updated). Applies optimistic locking via lock_version.

Examples

{:ok, updated} = Accrue.Billing.put_data(customer, %{"balance" => 0})

refund(charge, opts \\ [])

refund!(charge, opts \\ [])

release_schedule(sched, opts \\ [])

release_schedule!(sched, opts \\ [])

remove_item(item, opts \\ [])

remove_item!(item, opts \\ [])

render_invoice_pdf(invoice_or_id, opts \\ [])

report_usage(customer, event_name, opts \\ [])

report_usage/3 records a metered usage event for customer (a %Accrue.Billing.Customer{} or Stripe customer id string) and event_name, persisting through the transactional outbox before invoking the configured processor.

Options

Keys mirror Accrue.Billing.MeterEventActions's @report_usage_schema (types and defaults stay in sync there):

  • :value — non-negative integer count; default 1.
  • :timestamp%DateTime{}, Unix seconds as integer, or nil. When nil, normalization uses the current UTC instant; see Accrue.Billing.MeterEventActions for the exact normalization pipeline.
  • :identifier — string or nil; default nil derives a stable audit-layer identifier from customer, event_name, :value, the resolved timestamp, and optional :operation_id (uniqueness enforced on accrue_meter_events.identifier).
  • :operation_id — string or nil; when set, it participates in identifier derivation and supports idempotent replays alongside the other fields above.
  • :payload — map of extra dimensions or nil; default nil. Forwarded to the processor as supplemental context (e.g. %{"dimension" => "seats"} in tests).

Invalid opts raise NimbleOptions.ValidationError from NimbleOptions.validate!/2.

Error tuples vs persisted rows

{:error, _} means this call could not advance durable meter state as requested (for example the processor rejected the usage report). After retries with the same idempotency inputs, {:ok, %Accrue.Billing.MeterEvent{}} may be returned when the row already reflects a terminal outcome — inspect stripe_status and stripe_error on the persisted row for the canonical failure details. See guides/metering.md for how public calls, internal rows, and the processor seam relate.

Fake / test mode

Host apps can configure Accrue.Processor.Fake (for example via Accrue.Test.setup_fake_processor/1) to exercise this path without outbound network calls.

report_usage!(customer, event_name, opts \\ [])

Bang variant of report_usage/3 — returns %Accrue.Billing.MeterEvent{} or raises on error.

See report_usage/3 for the full options reference (## Options).

Raises NimbleOptions.ValidationError when opts fail validation.

Raises on {:error, _} from the underlying implementation when that tuple indicates a true failure for this invocation (for example a missing customer or a processor error that performed the failing attempt). When the non-bang report_usage/3 would return {:ok, row} on an idempotent replay (including a row already in failed), this function returns that row without raising. Accrue.APIError (including resource_missing / HTTP 404) is re-raised when it implements Exception; other error tuples become a RuntimeError raised by Accrue.Billing.MeterEventActions.report_usage!/3.

resolve_discount_mapping(code, checkout_amount_minor, opts \\ [])

resume(sub, opts \\ [])

resume!(sub, opts \\ [])

send_invoice(invoice, opts \\ [])

send_invoice!(invoice, opts \\ [])

set_default_payment_method(customer, pm_id, opts \\ [])

set_default_payment_method!(customer, pm_id, opts \\ [])

store_invoice_pdf(invoice_or_id, opts \\ [])

subscribe(user, price_id_or_opts \\ [], opts \\ [])

subscribe!(user, price_id_or_opts \\ [], opts \\ [])

subscribe_via_schedule(billable, phases, opts \\ [])

subscribe_via_schedule!(billable, phases, opts \\ [])

swap_plan(sub, new_price_id, opts)

Official active-subscription-change facade for replacing the current subscription price with a new price_id.

Use preview_upcoming_invoice/2 as the canonical path where supported before committing a swap. See guides/lifecycle_semantics.md for the semantic contract and .planning/processor-support-matrix.md for the provider-honest support boundary.

swap_plan!(sub, new_price_id, opts)

Raising variant of swap_plan/3.

sync_payment_methods(customer, opts \\ [])

sync_payment_methods!(customer, opts \\ [])

unpause(sub, opts \\ [])

unpause!(sub, opts \\ [])

update_customer(customer, attrs)

@spec update_customer(
  %Accrue.Billing.Customer{
    __meta__: term(),
    charges: term(),
    data: term(),
    default_payment_method: term(),
    default_payment_method_id: term(),
    email: term(),
    id: term(),
    inserted_at: term(),
    invoices: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    name: term(),
    owner_id: term(),
    owner_type: term(),
    payment_methods: term(),
    preferred_locale: term(),
    preferred_timezone: term(),
    processor: term(),
    processor_id: term(),
    subscriptions: term(),
    updated_at: term()
  },
  map()
) :: {:ok, Accrue.Billing.Customer.t()} | {:error, term()}

Updates a processor-backed Customer with the bounded shared contract.

Supported attrs are limited to name, email, and flat string metadata. The processor is updated first, then the sanitized processor response is projected into the local row and an "customer.updated" event is recorded in the same local transaction.

Examples

{:ok, customer} = Accrue.Billing.update_customer(customer, %{metadata: %{"tier" => "pro"}})

update_customer_local(customer, attrs)

@spec update_customer_local(
  %Accrue.Billing.Customer{
    __meta__: term(),
    charges: term(),
    data: term(),
    default_payment_method: term(),
    default_payment_method_id: term(),
    email: term(),
    id: term(),
    inserted_at: term(),
    invoices: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    name: term(),
    owner_id: term(),
    owner_type: term(),
    payment_methods: term(),
    preferred_locale: term(),
    preferred_timezone: term(),
    processor: term(),
    processor_id: term(),
    subscriptions: term(),
    updated_at: term()
  },
  map()
) :: {:ok, Accrue.Billing.Customer.t()} | {:error, term()}

Updates only the local Customer row for host-owned maintenance.

This explicit path preserves the broad local Customer.changeset/2 behavior without implying processor support.

update_customer_tax_location(customer, attrs)

@spec update_customer_tax_location(
  %Accrue.Billing.Customer{
    __meta__: term(),
    charges: term(),
    data: term(),
    default_payment_method: term(),
    default_payment_method_id: term(),
    email: term(),
    id: term(),
    inserted_at: term(),
    invoices: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    name: term(),
    owner_id: term(),
    owner_type: term(),
    payment_methods: term(),
    preferred_locale: term(),
    preferred_timezone: term(),
    processor: term(),
    processor_id: term(),
    subscriptions: term(),
    updated_at: term()
  },
  map()
) :: {:ok, Accrue.Billing.Customer.t()} | {:error, term()}

Updates a processor-backed customer's tax location with immediate validation.

This public path remains distinct from update_customer/2, which is the bounded shared customer-update contract.

update_customer_tax_location!(customer, attrs)

@spec update_customer_tax_location!(
  %Accrue.Billing.Customer{
    __meta__: term(),
    charges: term(),
    data: term(),
    default_payment_method: term(),
    default_payment_method_id: term(),
    email: term(),
    id: term(),
    inserted_at: term(),
    invoices: term(),
    last_stripe_event_id: term(),
    last_stripe_event_ts: term(),
    lock_version: term(),
    metadata: term(),
    name: term(),
    owner_id: term(),
    owner_type: term(),
    payment_methods: term(),
    preferred_locale: term(),
    preferred_timezone: term(),
    processor: term(),
    processor_id: term(),
    subscriptions: term(),
    updated_at: term()
  },
  map()
) :: Accrue.Billing.Customer.t()

Raising variant of update_customer_tax_location/2.

update_item_quantity(item, quantity, opts \\ [])

update_item_quantity!(item, quantity, opts \\ [])

update_payment_method(payment_method, attrs, opts \\ [])

update_payment_method!(payment_method, attrs, opts \\ [])

update_quantity(sub, quantity, opts \\ [])

update_quantity!(sub, quantity, opts \\ [])

update_schedule(sched, params, opts \\ [])

update_schedule!(sched, params, opts \\ [])

upsert_discount_mapping(code, attrs, opts \\ [])

upsert_discount_mapping!(code, attrs, opts \\ [])

void_invoice(invoice, opts \\ [])

void_invoice!(invoice, opts \\ [])