# `PhoenixKit.Users.Referrals`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.15.1/lib/phoenix_kit/users/referrals.ex#L1)

Core-side runtime bridge to the optional `phoenix_kit_referrals` package.

The referral-codes feature lives in the standalone `phoenix_kit_referrals`
module. Core has **no compile-time dependency** on it — this facade resolves
the installed module at runtime by its `PhoenixKit.Module` key (`"referrals"`)
via `PhoenixKit.ModuleRegistry` and dispatches through it.

When the package isn't installed (or doesn't export a given function) every
call degrades safely: the system reads as disabled, lookups return `nil`, and
`use_code/2` is a no-op. That lets the registration / OAuth / magic-link flows
treat referrals as optional — with the module absent, the referral field never
appears and nothing is recorded.

The function surface here mirrors exactly what the signup flows call, so those
call sites only had to swap their alias to this module.

## Invite-only access gate

With `referral_codes_required` on, a referral code is supposed to be the only
way in. Enforcing that at each *creation* path does not work: OAuth and
magic-link signups can be started from URLs that never pass through the
registration form, and blocking them there produces dead ends rather than
admission control.

So enforcement is a **post-signup access gate**. An account may be created by
any means; until it is *satisfied* it can reach nothing but
`/users/referral` (where a code admits it) and log-out. `access_satisfied?/1`
is the single predicate, called from the same choke points that enforce email
confirmation — see `PhoenixKitWeb.Users.Auth`.

An account is satisfied when any of these holds:

1. It has `custom_fields["referral_satisfied_at"]` — written by
   `mark_satisfied/2` when a code is used at signup, entered on the parked
   screen, or accepted as part of an organization invitation.
2. It was created before invite-only took effect and
   `referral_grandfather_existing` is on (the default). See below.
3. It holds every enabled permission (`Scope.holds_all_enabled_permissions?/1`).
   This is a **hard exemption, independent of the grandfather setting** — an
   operator who turns grandfathering off on an install whose codes are all
   spent would otherwise lock themselves out with no way back in. It is keyed
   on permissions rather than a role name because that is what the rest of the
   admin gate uses, and Owner satisfies it by construction.
4. A pending, unexpired organization invitation is addressed to its email.
   Invitations are email-bound, so this makes org admins de-facto admission
   issuers on purpose — without it the "you were invited" banner on the
   registration page is a dead end under invite-only.

### Why not `confirmed_at`

The satisfied flag is deliberately independent of email confirmation. OAuth
auto-confirms (`PhoenixKit.Users.OAuth`), so a gate keyed on `confirmed_at`
would open for exactly the path it most needs to close.

### Grandfathering

`referral_required_enabled_at` records when invite-only took effect, and users
created before it are grandfathered while `referral_grandfather_existing`
(default `true`) is on. Core stamps and clears that setting itself, from
`access_required?/0`, rather than relying on the referrals admin toggle — that
way it stays correct whoever flips the switch, including an older
`phoenix_kit_referrals` that knows nothing about the gate. Turning invite-only
off clears the stamp, so switching it on again grandfathers everyone admitted
in between.

### If the package is uninstalled

The gate requires `enabled?/0`, which goes through the installed module. With
the package gone there is no way to *validate* a code, so gating on a
left-behind `referral_codes_required` row would park every non-exempt user
permanently.

# `access_required?`

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

Whether invite-only enforcement is currently active.

Also keeps `referral_required_enabled_at` in step with the answer, so the
grandfather boundary always describes the *current* invite-only period.

# `access_satisfied?`

```elixir
@spec access_satisfied?(
  PhoenixKit.Users.Auth.Scope.t()
  | PhoenixKit.Users.Auth.User.t()
  | nil
) ::
  boolean()
```

Whether this account may use the application, or must first enter a code.

Accepts a `Scope` or a `User`; anything else (including `nil`) is satisfied,
because deciding admission for a visitor with no account is the
authentication gate's job, not this one.

The `User` form exists for the older user-based `on_mount` hook, which has no
scope. It has to build one to evaluate the full-access exemption, so it is
the more expensive form — but only for an account that is not already
satisfied on cheaper grounds.

# `enabled?`

Whether a referrals module is installed and enabled.

# `expired?`

Whether the given code is expired.

⚠️ Answers **`true`** when nothing can answer. These predicates gate admission
under invite-only, so "we could not check" has to mean "do not admit" — the
old `false` default let a code past an expiry check that never ran.

# `get_code_by_string`

Look up a referral code struct by its string, or `nil`.

# `get_config`

Referral-codes configuration map. Disabled defaults when the module is absent.

# `mark_satisfied`

```elixir
@spec mark_satisfied(PhoenixKit.Users.Auth.User.t(), String.t()) ::
  {:ok, PhoenixKit.Users.Auth.User.t()} | {:error, term()}
```

Records that an account has satisfied invite-only, and why.

Idempotent: an already-marked account keeps its original timestamp, so a
second call cannot rewrite when admission happened. `reason` is stored
alongside for operators auditing how an account got in.

# `marked_satisfied?`

```elixir
@spec marked_satisfied?(PhoenixKit.Users.Auth.User.t() | nil) :: boolean()
```

Whether this account has already been marked as satisfying invite-only.

# `prune_candidates`

```elixir
@spec prune_candidates(pos_integer()) :: [PhoenixKit.Users.Auth.User.t()]
```

One sweep's worth of rows, before the per-account authority check.

Separated from `prune_unadmitted/1` so the grandfather exclusion can be
asserted directly. Testing it through the sweep would take
`500` accounts to demonstrate — the starvation it prevents
only appears once exempt rows can fill the batch — and a suite that registers
five hundred users to prove one `WHERE` clause is a suite nobody runs.

# `prune_unadmitted`

```elixir
@spec prune_unadmitted(non_neg_integer()) :: {:ok, non_neg_integer()}
```

Deactivates accounts that were created under invite-only and never satisfied
it, so "registered" keeps meaning "admitted".

**Deactivates; never deletes.** Users are the foreign-key target of most of
the ecosystem, and an account that was merely slow to enter its code is one
an operator will want back.

Nothing happens unless invite-only is on AND `days` is positive — an install
that turned invite-only off should not have the janitor sweeping up the
accounts it just legitimised. Candidates are re-checked one by one through
`access_satisfied?/1` rather than trusted from the query, because the
exemptions that matter most (full permissions, a pending invitation) are the
ones SQL cannot see.

Returns `{:ok, count}`.

# `record_signup_use`

```elixir
@spec record_signup_use(PhoenixKit.Users.Auth.User.t(), String.t() | map() | nil) ::
  :ok
```

Records a signup's use of a referral code and marks the account admitted.

The two halves belong together. `use_code/2` on its own leaves an account
that supplied a perfectly good code sitting behind the invite-only gate —
the exact opposite of what supplying it meant — and every signup path had
its own copy of the first half only.

Accepts the code as a string or as an already-resolved code struct; `nil`
(no code supplied) is a no-op. Marking happens even when invite-only is
currently off, so an operator who turns it on later does not park the people
who did bring a code.

# `redeem`

```elixir
@spec redeem(PhoenixKit.Users.Auth.User.t(), String.t()) ::
  {:ok, PhoenixKit.Users.Auth.User.t()} | {:error, term()}
```

Claims one use of `code_string` for `user` and marks the account admitted,
atomically.

The two writes have to succeed or fail together. Claiming without marking
burns a use of a possibly single-use code and leaves the user still parked,
with the same code now rejecting them; marking without claiming lets a code
past its limit admit everyone who raced for it.

Returns `{:ok, user}` or `{:error, reason}`.

# `satisfied_field`

The `custom_fields` key recording that an account satisfied invite-only.

# `unadmitted_retention_days`

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

How long an unadmitted account is left alone before the janitor deactivates
it, in days. `0` (the default) means never.

Deliberately opt-in. The alternative — a default that silently starts
deactivating accounts as soon as an operator switches invite-only on — is the
kind of destructive default that has to be asked for, not inherited.

# `usage_limit_reached?`

Whether the given code hit its usage limit. Fails closed — see `expired?/1`.

# `use_code`

Record a use of `code_string` by `user_uuid`.

No-op returning `{:error, :referrals_not_installed}` when the module is absent.

# `validate_for_signup`

Validates a referral code for a signup attempt.

Shared by every signup surface so the rules cannot drift apart — the password
and magic-link forms previously carried byte-identical private copies.

## Options

- `:enabled?` / `:required?` — from `get_config/0` (required)
- `:context` — `:change` while the user types, `:submit` on the final attempt
- `:ip_address` — used to rate-limit code checking; omit and no limit applies

## Why `:context` matters

On `:change`, **nothing is rejected**. Validation runs on every keystroke
anywhere in the form, so treating blank-and-required as invalid made "Referral
code is required" appear while the user was still typing their email — before
they had reached the field. `:submit` enforces presence.

A typed code is not checked on change either, which reverses an earlier
decision that a touched field was fair to complain about. Two reasons pointing
the same way: checking per keystroke hands an attacker a far faster oracle
than the submit button does, and it spends one rate-limit token per character,
so a real person typing an eight-character code and fixing a typo can exhaust
their own budget while holding a good code.

## Why rejections are indistinguishable

Every failure returns the same message. Separate strings for
missing / inactive / expired / limit-reached confirmed which guesses named a
real code. The specific reason is logged at debug level for operators.

Returns `{:ok, code_or_nil}` or `{:error, message}`.

---

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