# `Accrue.Config`
[🔗](https://github.com/szTheory/accrue/blob/accrue-v1.5.1/lib/accrue/config.ex#L1)

Runtime configuration schema for Accrue, backed by `NimbleOptions`.

This module is the **single source of truth** for supported `:accrue`
application keys. Host code reads validated values via `get!/1` or
`Application.get_env/3`; extend behaviour through adapters, not by editing
this schema from application code.

## Compile-time vs runtime

Adapter atoms (`:processor`, `:mailer`, `:mailer_adapter`,
`:invoice_pdf_adapter`, `:pdf_adapter`, `:auth_adapter`) are stable per-deploy and fine at compile time via
`Application.compile_env!/2`.

Secrets (`:stripe_secret_key`) and host-owned fields (`:default_currency`,
`:from_email`, brand colors) MUST be read at runtime. See CLAUDE.md
§Config Boundaries.

## Options

* `:repo` (`t:atom/0`) - Required. Host `Ecto.Repo` module that Accrue writes to (event ledger, webhook events, billing tables).

* `:billing_schema` - PostgreSQL schema where Accrue-owned billing tables are stored. Defaults to `billing`; set to `public` explicitly to keep Accrue tables in the default schema. This is compile-time configuration for Ecto schemas and migration generation. The default value is `"billing"`.

* `:processor` (`t:atom/0`) - Processor adapter implementing `Accrue.Processor` behaviour. The default value is `Accrue.Processor.Fake`.

* `:rails` (`t:keyword/0`) - Additive entitlement rail registrations. Omit to retain legacy single-processor behaviour. The default value is `[]`.

  * `:source` (`t:atom/0`) - Required.

  * `:processor` - The default value is `nil`.

  * `:environments` (list of `t:atom/0`) - The default value is `[]`.

  * `:default_environment` - The default value is `nil`.

* `:default_rail` - The registered controllable rail that agrees with the legacy :processor alias. The default value is `nil`.

* `:mailer` (`t:atom/0`) - Mailer pipeline module implementing `Accrue.Mailer` behaviour. The default value is `Accrue.Mailer.Default`.

* `:mailer_adapter` (`t:atom/0`) - Swoosh-backed mailer delivery module. The default value is `Accrue.Mailer.Swoosh`.

* `:invoice_pdf_adapter` (`t:atom/0`) - Invoice PDF adapter implementing `Accrue.InvoiceRenderer` behaviour. Defaults to the native Rendro-backed invoice renderer. The default value is `Accrue.InvoiceRenderer.Rendro`.

* `:pdf_adapter` (`t:atom/0`) - Legacy HTML-to-PDF adapter implementing `Accrue.PDF` behaviour. Used by the Chromic invoice renderer path and by custom HTML callers. The default value is `Accrue.PDF.ChromicPDF`.

* `:auth_adapter` (`t:atom/0`) - Auth adapter implementing `Accrue.Auth` behaviour. The default value is `Accrue.Auth.Default`.

* `:plan_resolver` - Optional host-owned resolver module implementing `Accrue.PlanResolver`. Required when a processor needs more than a bare `price_id` to perform plan changes through the shared facade (for example Braintree swap-plan flows). The default value is `nil`.

* `:storage_adapter` (`t:atom/0`) - Storage adapter implementing `Accrue.Storage` behaviour. v1.0 ships `Accrue.Storage.Null` only; hosts supply a custom adapter (e.g., S3) to enable persisted asset storage. `Accrue.Storage.Filesystem` ships in v1.1. The default value is `Accrue.Storage.Null`.

* `:stripe_secret_key` (`t:String.t/0`) - Runtime Stripe secret key. MUST be read at runtime only; never via `Application.compile_env!/2`. Validated at boot when `processor == Accrue.Processor.Stripe`.

* `:stripe_api_version` (`t:String.t/0`) - Stripe API version pinned by the `:lattice_stripe` wrapper. The default value is `"2026-03-25.dahlia"`.

* `:emails` (`t:keyword/0`) - Per-email-type switches. Keys are email type atoms; values are `boolean` or `{Mod, :fun, args}` MFA callbacks. The default value is `[]`.

* `:email_overrides` (`t:keyword/0`) - Per-email-type template module overrides (third rung of the override ladder; see `guides/email.md`). Keys are email type atoms; values are module names. The default value is `[]`.

* `:attach_invoice_pdf` (`t:boolean/0`) - Auto-attach invoice PDF to the receipt email. The default value is `true`.

* `:enforce_immutability` (`t:boolean/0`) - When true, `Accrue.Application` boot raises if the current PG role has UPDATE/DELETE on `accrue_events`. The default value is `false`.

* `:business_name` (`t:String.t/0`) - Business name shown in email headers, PDFs, and admin UI. The default value is `"Accrue"`.

* `:business_address` (`t:String.t/0`) - Business postal address shown in invoice footers. The default value is `""`.

* `:logo_url` (`t:String.t/0`) - Absolute URL to the brand logo used in email + PDF headers. The default value is `""`.

* `:support_email` (`t:String.t/0`) - Reply-to support email address for transactional mail. The default value is `"support@example.com"`.

* `:from_email` (`t:String.t/0`) - Default From: address for transactional mail. The default value is `"noreply@example.com"`.

* `:from_name` (`t:String.t/0`) - Default From: name for transactional mail. The default value is `"Accrue"`.

* `:default_currency` (`t:atom/0`) - Default currency when one is not explicitly supplied. The default value is `:usd`.

* `:webhook_signing_secrets` (`t:term/0`) - Map of processor atom to signing secret(s). Each value is a string or list of strings for rotation. Example: `%{stripe: ["whsec_old", "whsec_new"]}`. The default value is `%{}`.

* `:succeeded_retention_days` - Number of days to retain `:succeeded` webhook events before the Pruner deletes them. Set to `:infinity` to disable pruning. Default: 14. The default value is `14`.

* `:dead_retention_days` - Number of days to retain `:dead` webhook events before the Pruner deletes them. Set to `:infinity` to disable pruning. Default: 90. The default value is `90`.

* `:webhook_handlers` (list of `t:atom/0`) - List of modules implementing `Accrue.Webhook.Handler` behaviour. Called sequentially after the default handler on each webhook event. Example: `[MyApp.BillingHandler, MyApp.AnalyticsHandler]`. The default value is `[]`.

* `:portal_mount_path` (`t:String.t/0`) - Mount path for the first-party `Accrue.Portal` routes when a processor uses local portal semantics. Returned checkout / billing-portal URLs append to this normalized path after `:portal_base_url` supplies the absolute host. The default value is `"/billing"`.

* `:portal_base_url` - Absolute base URL (for example `https://app.example.com`) used to generate returned local portal checkout and billing-portal URLs. Leave unset only when those local portal URLs are not in use; `portal_url/1` raises instead of falling back to a relative path. The default value is `nil`.

* `:braintree_client_token_generator` (`t:atom/0`) - Module used to generate Braintree client tokens for Hosted Fields. Must export `generate/1`. The default value is `Braintree.ClientToken`.

* `:expiring_card_thresholds` - Strictly-descending list of day thresholds at which the expiring-card reminder email fires ahead of a stored card's expiration. Default: `[30, 7, 1]` — 30, 7, and 1 days out. The default value is `[30, 7, 1]`.

* `:idempotency_mode` - How `Accrue.Actor.current_operation_id!/0` behaves when the process dict has no operation_id. `:strict` raises `Accrue.ConfigError`; `:warn` (the default) generates a random UUID and logs a warning. Set to `:strict` in production to ensure every outbound processor call carries a deterministic idempotency key. The default value is `:warn`.

* `:succeeded_refund_retention_days` (`t:pos_integer/0`) - Number of days to retain `:succeeded` refund records before pruning Default: 90. The default value is `90`.

* `:dunning` (`t:keyword/0`) - Dunning grace-period overlay config. `:mode` is `:stripe_smart_retries` or `:disabled`; `:terminal_action` is `:unpaid` or `:canceled`; `:grace_days` adds N days past Stripe's last retry before Accrue asks the processor facade to move the subscription to the terminal action. `:campaign` is the multi-step dunning cadence (see its own doc). The default value is `[mode: :stripe_smart_retries, grace_days: 14, terminal_action: :unpaid, telemetry_prefix: [:accrue, :ops], campaign: [enabled: true, steps: [[after_days: 0, key: :reminder, template: Accrue.Emails.InvoicePaymentFailed], [after_days: 5, key: :action_required, template: Accrue.Emails.DunningActionRequired], [after_days: 12, key: :final_notice, template: Accrue.Emails.DunningFinalNotice]]], engine: Accrue.Dunning.Engine.Oban]`.

  * `:mode` - The default value is `:stripe_smart_retries`.

  * `:grace_days` (`t:pos_integer/0`) - The default value is `14`.

  * `:terminal_action` - The default value is `:unpaid`.

  * `:telemetry_prefix` (list of `t:atom/0`) - The default value is `[:accrue, :ops]`.

  * `:campaign` - Multi-step dunning cadence (D-04). A keyword list of `[enabled: boolean, steps: [...]]` where each step is `[after_days: non_neg_integer, key: atom, template: atom]`. `after_days` is ABSOLUTE from campaign start, strictly increasing and unique across the list; `key` is required and unique (it becomes the Oban-unique step identity). Ships a default journey ON by default (offsets `[0, 5, 12]`); set `campaign: false` (normalized to `[enabled: false, steps: []]`) to opt out. `steps: []` while `enabled: true` is a loud error. The last step's `after_days` MUST be `<= grace_days` (validated loud at boot) so the final notice precedes the sweeper's terminal action. The default value is `[enabled: true, steps: [[after_days: 0, key: :reminder, template: Accrue.Emails.InvoicePaymentFailed], [after_days: 5, key: :action_required, template: Accrue.Emails.DunningActionRequired], [after_days: 12, key: :final_notice, template: Accrue.Emails.DunningFinalNotice]]]`.

  * `:engine` (`t:atom/0`) - Module implementing `Accrue.Dunning.Engine`. Default: `Accrue.Dunning.Engine.Oban` (built-in Oban campaign). Set to `Accrue.Integrations.Chimeway` to delegate orchestration to Chimeway. The default value is `Accrue.Dunning.Engine.Oban`.

* `:webhook_endpoints` (`t:keyword/0`) - Map of endpoint name to `[secret:, mode:]` for multi-endpoint webhooks. Example: `[primary: [secret: "whsec_..."], connect: [secret: "whsec_...", mode: :connect]]`. The default value is `[]`.

* `:dlq_replay_batch_size` (`t:pos_integer/0`) - Number of rows per chunk in `Accrue.Webhooks.DLQ.requeue_where/2` bulk replay. The default value is `100`.

* `:dlq_replay_stagger_ms` (`t:non_neg_integer/0`) - Milliseconds to sleep between chunks during DLQ bulk replay (protects downstream). Default: 1_000. The default value is `1000`.

* `:dlq_replay_max_rows` (`t:pos_integer/0`) - Hard cap on bulk replay. Returns `{:error, :replay_too_large}` unless `force: true` is passed. Default: 10_000. The default value is `10000`.

* `:branding` (`t:keyword/0`) - Branding config. Single source of truth for email + PDF brand. Read-only hosts may omit it; configure `:from_email` and `:support_email` before enabling email delivery. See guides/branding.md. The default value is `[]`.

  * `:business_name` (`t:String.t/0`) - The default value is `"Accrue"`.

  * `:from_name` (`t:String.t/0`) - The default value is `"Accrue"`.

  * `:from_email` - The default value is `nil`.

  * `:support_email` - The default value is `nil`.

  * `:reply_to_email` - The default value is `nil`.

  * `:logo_url` - The default value is `nil`.

  * `:logo_dark_url` - The default value is `nil`.

  * `:accent_color` - The default value is `"#1F6FEB"`.

  * `:secondary_color` - The default value is `"#6B7280"`.

  * `:font_stack` (`t:String.t/0`) - The default value is `"-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif"`.

  * `:company_address` - The default value is `nil`.

  * `:support_url` - The default value is `nil`.

  * `:social_links` (`t:keyword/0`) - The default value is `[]`.

  * `:list_unsubscribe_url` - The default value is `nil`.

  * `:theme` - Customer portal color-mode policy. `:system` (default) offers light + dark with a picker (follows the OS, user-switchable via the `accrue_theme` cookie). `:light` or `:dark` locks the portal to that mode and hides the picker. The default value is `:system`.

* `:admin_branding` (`t:keyword/0`) - Optional Accrue Admin chrome branding. When unset, admin chrome keeps the legacy behavior and derives its app name, logo, and accent from `:branding`. Set this when customer-facing billing should use one brand while the mounted operator UI keeps an Accrue/admin identity. The default value is `[]`.

  * `:app_name` (`t:String.t/0`) - The default value is `"Billing"`.

  * `:logo_url` - The default value is `nil`.

  * `:accent_color` - The default value is `"#5D79F6"`.

* `:default_locale` (`t:String.t/0`) - Application-wide default locale for email + PDF rendering. Third rung of the locale precedence ladder (after assigns[:locale] and customer.preferred_locale). Bad locales fall back to "en". The default value is `"en"`.

* `:default_timezone` (`t:String.t/0`) - Application-wide default IANA timezone for datetime rendering. Third rung of the timezone precedence ladder (after assigns[:timezone] and customer.preferred_timezone). Bad zones fall back to "Etc/UTC". The default value is `"Etc/UTC"`.

* `:cldr_backend` (`t:atom/0`) - Cldr backend module used by `Accrue.Workers.Mailer.enrich/2` to validate locale strings. Defaults to `Accrue.Cldr`. The default value is `Accrue.Cldr`.

* `:connect` (`t:keyword/0`) - Stripe Connect configuration. `:default_stripe_account` is the fallback connected account id used when no per-call override or pdict scope is active (three-level precedence chain). `:platform_fee` configures the default flat-rate fee consumed by `Accrue.Connect.platform_fee/2`: `:percent` is a `Decimal` percentage (e.g. `Decimal.new("2.9")` for 2.9%), `:fixed` is an `Accrue.Money` fee in minor units added after the percentage, and `:min`/`:max` optionally clamp the result. The default value is `[default_stripe_account: nil, platform_fee: [percent: Decimal.new("2.9"), fixed: nil, min: nil, max: nil]]`.

* `:entitlements` (`t:keyword/0`) - Plan->feature/quota entitlement catalog (host-owned, runtime). Boot-validated and the same `price_id` may not map to two plans (raises `Accrue.ConfigError` at boot). See guides/entitlements.md. The default value is `[]`.

  * `:plans` (`t:keyword/0`) - Logical plan name (atom) -> entitlement entry (features/limits/price_ids) The default value is `[]`.

    * `:features` (list of `t:atom/0`) - The default value is `[]`.

    * `:limits` (`t:keyword/0`) - The default value is `[]`.

    * `:price_ids` (list of `t:String.t/0`) - The default value is `[]`.

    * `:products` (`t:keyword/0`) - The default value is `[]`.

  * `:resolver` (`t:atom/0`) - Resolver module (Accrue.Entitlements.Resolver behaviour). Default LocalMap. The default value is `Accrue.Entitlements.Resolver.LocalMap`.

  * `:unmapped_action` - Behaviour when an active price_id is unmapped. :deny fails closed; never silent-allow. The default value is `:deny`.

  * `:billable` - Global billable resolver: a 1-arity fn `(conn | socket -> billable | nil)`. When unset, the default probes `current_scope.user -> current_user -> nil`. Must never raise — fail closed (resolve to nil) on any miss. The default value is `nil`.

  * `:on_deny` - Global deny handler. Default is a content-negotiated opaque 403. Accepts `:forbidden | {:redirect, path} | {status, body} | fun/2 | {m,f,a}`. A per-guard `on_deny:` opt overrides this; this overrides the built-in 403. A malformed value fails loud at boot (never silently fails open). The default value is `:forbidden`.

  * `:deny_path` (`t:String.t/0`) - LiveView fallback redirect target for `:forbidden` / non-redirectable denies. The default value is `"/"`.

  * `:past_due_grace` - Entitlement access for :past_due subscriptions. :none (default) fails closed immediately. :dunning honors the dunning grace window (reuses Accrue.Config.dunning()[:grace_days]). A positive integer N honors an entitlement-specific N-day window. Grace grants are affirmative, resolved, configured decisions — never a fail-open. The default value is `:none`.

  * `:stripe_native_sync` - Optional Stripe-native entitlement-summary sync. :disabled (default) is fully inert — no webhook reducer runs, no cache table is read or written. :advisory records Stripe entitlement summaries to an advisory cache for audit / telemetry / the admin read-seam; it does NOT change entitled?/has_active_plan? decisions in v1.x (local mapping stays canonical). The enum (not a boolean) reserves room for future additive modes without a breaking config change. The default value is `:disabled`.

  * `:multi_rail` (`t:keyword/0`) - Compatibility authority lane for canonical entitlement projection. Omit it (or use :disabled) to retain LocalMap authority; :shadow and :enabled require an explicit approved-account/MFA cohort. The default value is `[]`.

    * `:mode` - The default value is `:disabled`.

    * `:cohort` (`t:term/0`) - The default value is `nil`.

    * `:clean_window` (`t:term/0`) - The default value is `nil`.

# `admin_branding`

```elixir
@spec admin_branding() :: keyword()
```

Returns branding for the mounted Accrue Admin operator chrome.

Backwards compatible default: when `:admin_branding` is unset, derive the
admin label, logo, and accent from the customer billing `:branding` config.
Hosts that need a split identity can set `:admin_branding` explicitly.

# `admin_branding`

```elixir
@spec admin_branding(atom()) :: term()
```

Returns a single admin branding key. Raises if the key is unknown.

# `billing_schema`

```elixir
@spec billing_schema() :: String.t()
```

Runtime billing schema prefix used by migration and raw SQL helpers.

# `branding`

```elixir
@spec branding() :: keyword()
```

Returns the branding config keyword list.

Reads the nested `:branding` keyword list from app env, merging any
unset keys with their schema defaults so callers can `Keyword.fetch!`
any valid key without re-running the full NimbleOptions validator on
every call site.

# `branding`

```elixir
@spec branding(atom()) :: term()
```

Returns a single branding key. Raises if the key is unknown.

# `cldr_backend`

```elixir
@spec cldr_backend() :: module()
```

Returns the configured Cldr backend module used by
`Accrue.Workers.Mailer.enrich/2` to validate locale strings.

# `compile_time_billing_schema`

```elixir
@spec compile_time_billing_schema() :: String.t()
```

Compile-time billing schema prefix used by Accrue Ecto schemas.

# `connect`

```elixir
@spec connect() :: keyword()
```

Returns the Connect config keyword list.

Shape: `[default_stripe_account: String.t() | nil,
          platform_fee: [percent: Decimal.t(), fixed: Accrue.Money.t() | nil,
                         min: Accrue.Money.t() | nil, max: Accrue.Money.t() | nil]]`.

# `dead_retention_days`

```elixir
@spec dead_retention_days() :: pos_integer() | :infinity
```

Returns the number of days to retain `:dead` webhook events.

# `default_locale`

```elixir
@spec default_locale() :: String.t()
```

Returns the application default locale string.

# `default_rail`

```elixir
@spec default_rail() :: atom() | nil
```

Returns the configured controllable entitlement rail, when rails are configured.

# `default_timezone`

```elixir
@spec default_timezone() :: String.t()
```

Returns the application default IANA timezone string.

# `dlq_replay_batch_size`

```elixir
@spec dlq_replay_batch_size() :: pos_integer()
```

Returns the DLQ bulk-replay chunk size.

# `dlq_replay_max_rows`

```elixir
@spec dlq_replay_max_rows() :: pos_integer()
```

Returns the hard cap on DLQ bulk-replay rows.

# `dlq_replay_stagger_ms`

```elixir
@spec dlq_replay_stagger_ms() :: non_neg_integer()
```

Returns the DLQ bulk-replay inter-chunk stagger in milliseconds.

# `dunning`

```elixir
@spec dunning() :: keyword()
```

Returns the dunning grace-period overlay config.

# `dunning_campaign`

```elixir
@spec dunning_campaign() :: keyword()
```

Returns the dunning campaign cadence config (D-07).

Raw read with its own nested default — `dunning/0` does not normalize the
nested `:campaign` key (identical constraint to `past_due_grace/0`), so
the default journey is supplied here when a host overrides `:dunning`
without re-stating `:campaign`. Ships ON by default.

# `dunning_campaign_enabled?`

```elixir
@spec dunning_campaign_enabled?() :: boolean()
```

Returns whether the multi-step dunning campaign is enabled (D-07).

`false` when the campaign is opted out (`campaign: false` /
`enabled: false`). Fail-closed: an absent `:enabled` key reads as `false`.

# `dunning_campaign_steps`

```elixir
@spec dunning_campaign_steps() :: [keyword()]
```

Returns the configured dunning campaign steps (D-07).

Returns `[]` when the campaign is disabled (so downstream consumers never
fire steps for an opted-out host), else the configured step list.

# `dunning_engine`

```elixir
@spec dunning_engine() :: module()
```

Returns the configured dunning engine module (D-03).

Defaults to `Accrue.Dunning.Engine.Oban` (built-in Oban campaign).
Set `dunning: [engine: Accrue.Integrations.Chimeway]` to opt into
Chimeway orchestration.

The atom is returned as-is without loading the module — resolution happens
at dispatch time in the calling code.

# `entitlement_product_catalog`

```elixir
@spec entitlement_product_catalog() :: %{
  optional({atom(), atom(), String.t()}) =&gt; atom()
}
```

Returns logical plans indexed by `{rail, environment, product_id}`.

# `entitlements`

```elixir
@spec entitlements() :: keyword()
```

Returns the runtime `:entitlements` config keyword list (the plan->feature/
quota catalog), or the schema default `[]` when unset.

This is a thin runtime accessor (`get!/1`, NOT `compile_env!`) because
`:entitlements` is host-owned catalog data that legitimately differs per
environment (e.g. test-mode vs live-mode `price_ids`), like `:branding` and
`:dunning`. Unlike `branding/0` (which merges schema defaults), this is a
**raw** runtime read — it does NOT apply the nested per-plan defaults
(`features: []`, `limits: []`, `price_ids: []`). `validate_at_boot!/0`
validates the config but discards the normalized result and never writes it
back to app env. The resolver tolerates missing nested keys via
`Keyword.get/3` defaults, so it reads this raw value without re-running the
full validator.

# `get!`

```elixir
@spec get!(atom()) :: term()
```

Reads a config key from `Application.get_env/3`, falling back to the
schema default. Raises `Accrue.ConfigError` if the key is not in the
schema at all (prevents silent typos in downstream code).

# `multi_rail`

```elixir
@spec multi_rail() :: keyword()
```

Returns the validated compatibility authority configuration.

# `normalize_mount_path`

```elixir
@spec normalize_mount_path(String.t()) :: String.t()
```

# `past_due_grace`

```elixir
@spec past_due_grace() :: :none | :dunning | pos_integer()
```

Returns the past-due entitlement grace policy: `:none` (default,
fail-closed), `:dunning` (reuse the dunning grace window), or a positive
integer of days.

The `:none` default is applied here because `entitlements/0` does a raw
read without normalizing nested `:entitlements` defaults. The
`:dunning` -> `grace_days` resolution is done by the resolver when it
widens the fold, not by this accessor.

# `plan_resolver`

```elixir
@spec plan_resolver() :: module() | nil
```

# `portal_base_url`

```elixir
@spec portal_base_url() :: String.t() | nil
```

# `portal_mount_path`

```elixir
@spec portal_mount_path() :: String.t()
```

# `portal_url`

```elixir
@spec portal_url(String.t()) :: String.t()
```

# `rails`

```elixir
@spec rails() :: keyword()
```

Returns configured entitlement rails, or [] when a host uses the legacy processor path.

# `schema`

```elixir
@spec schema() :: keyword()
```

Returns the NimbleOptions schema keyword list. Used by boot-time
validation to iterate keys.

# `stripe_api_version`

```elixir
@spec stripe_api_version() :: String.t()
```

Returns the configured Stripe API version string.

# `stripe_native_sync`

```elixir
@spec stripe_native_sync() :: :disabled | :advisory
```

Returns the optional Stripe-native entitlement-sync mode: `:disabled`
(default, fully inert) or `:advisory` (record summaries to the advisory
cache for audit / telemetry / the admin read-seam — does NOT change
`entitled?`/`has_active_plan?` decisions; local mapping stays canonical).

The `:disabled` default is supplied here because `entitlements/0` does a
raw read without normalizing nested `:entitlements` defaults (identical
constraint to `past_due_grace/0`).

# `stripe_native_sync?`

```elixir
@spec stripe_native_sync?() :: boolean()
```

Ergonomic predicate for the Stripe-native sync gate: `true` when sync is
enabled in any non-`:disabled` mode, `false` otherwise. The webhook
reducer dispatch checks this FIRST so the off lane early-returns before
any `Repo` call.

# `succeeded_retention_days`

```elixir
@spec succeeded_retention_days() :: pos_integer() | :infinity
```

Returns the number of days to retain `:succeeded` webhook events.

# `validate!`

```elixir
@spec validate!(keyword()) :: keyword()
```

Validates a keyword list against the Accrue config schema and returns the
normalized form. Raises `NimbleOptions.ValidationError` on failure.

# `validate_at_boot!`

```elixir
@spec validate_at_boot!() :: :ok
```

Reads the current `:accrue` application env at boot time, filters it to
the schema-known keys, and validates via `NimbleOptions.validate!/2`.

Called by `Accrue.Application.start/2` before the supervision tree
starts. Raises `NimbleOptions.ValidationError` on misconfig — fail loud
rather than limp into production with silently-broken config.

Only schema-known keys are validated. Extra keys in the `:accrue` env
(e.g., per-module adapter configs like `Accrue.Mailer.Swoosh`) are
ignored here — they belong to their own libraries and would otherwise
produce spurious `unknown option` errors.

# `validate_descending`

```elixir
@spec validate_descending(term()) :: {:ok, [pos_integer()]} | {:error, String.t()}
```

NimbleOptions `:custom` validator for `:expiring_card_thresholds`.

Accepts a non-empty list of positive integers that is strictly
descending (each element strictly less than the previous). Returns
`{:ok, list}` on success, `{:error, message}` on failure.

# `validate_dunning_campaign`

```elixir
@spec validate_dunning_campaign(term()) :: {:ok, keyword()} | {:error, String.t()}
```

NimbleOptions `:custom` validator for the `:dunning.campaign` cadence
(DUN-01, D-04/D-05).

Accepts:

  * `false` — normalized to `[enabled: false, steps: []]` (opt-out, D-05).
  * a keyword list `[enabled: boolean, steps: [...]]` where each step is a
    keyword list `[after_days: non_neg_integer, key: atom, template: atom]`
    validated against `@step_schema`.

Intra-list invariants (the cross-field `last_step.after_days <= grace_days`
invariant is checked separately at boot by
`validate_dunning_campaign_grace!/1`, since a `{:custom}` validator cannot
read the sibling `:grace_days` key):

  * `after_days` strictly increasing AND unique across the list.
  * `key` unique across the list.
  * when `enabled: true`, `steps` MUST be non-empty — `steps: []` while
    enabled is a LOUD `{:error, _}` (never a silent disable, D-05).

Returns `{:ok, normalized_keyword}` on success, `{:error, message}` on
failure (so boot validation fails loud rather than silently mis-firing).

# `validate_hex`

```elixir
@spec validate_hex(term()) :: {:ok, String.t()} | {:error, String.t()}
```

NimbleOptions `:custom` validator for `:branding.accent_color` /
`:branding.secondary_color`. Accepts `#rgb`, `#rrggbb`, and
`#rrggbbaa` hex color strings; rejects anything else.

# `validate_on_deny`

```elixir
@spec validate_on_deny(term()) :: {:ok, term()} | {:error, String.t()}
```

NimbleOptions `:custom` validator for the global `:entitlements`
`on_deny` handler (Phase 124, ENT-06).

Accepts any of the supported deny-handler shapes and returns
`{:ok, value}`; a malformed value returns `{:error, message}` so boot
validation (`validate_at_boot!/0`) fails loud rather than letting a
broken deny path silently fail open (T-124-01).

Supported shapes:

  * `:forbidden` — built-in opaque 403 / LiveView redirect to `deny_path`.
  * `{:redirect, path}` when `path` is a binary.
  * `{status, body}` when `status` is an integer and `body` is a binary.
  * a 2-arity `fun` `(container, ctx -> result)`.
  * an MFA `{m, f, a}` when `m`/`f` are atoms and `a` is a list.

# `webhook_endpoints`

```elixir
@spec webhook_endpoints() :: keyword()
```

Returns the multi-endpoint webhook config.

# `webhook_handlers`

```elixir
@spec webhook_handlers() :: [module()]
```

Returns the list of user-registered webhook handler modules.

# `webhook_signing_secrets`

```elixir
@spec webhook_signing_secrets(atom()) :: String.t() | [String.t()]
```

Returns the signing secret(s) for the given processor.

Looks up `webhook_signing_secrets` in the `:accrue` application env
and extracts the value for the given processor atom. Returns a list
of strings (for multi-secret rotation support). Raises
`Accrue.ConfigError` if no secrets are configured for the processor.

---

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