# Ussd

![Hex.pm](https://img.shields.io/hexpm/v/ussd) ![Hex.pm](https://img.shields.io/hexpm/dt/ussd)

Build USSD (Unstructured Supplementary Service Data) applications in Elixir without
breaking a sweat.

This README covers installation and a quick example. For every feature in depth - Record,
Decisions, Pagination & Truncation, Resuming Sessions, Configurators, Localization,
Encrypted Records, Gateway Responses, the `mix ussd.graph`/`ussd.simulate`/`ussd.lint`
tasks, Testing, and more - see the **[full guide](GUIDE.md)**.

## Installation

Add `ussd` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:ussd, "~> 0.2.0"}
  ]
end
```

## Features

- **Menus as modules** — define screens as `Ussd.State` modules with a fluent `Ussd.Menu`
  builder, and route between them declaratively with `transition/2`.
- **Built-in decisions** — match input with `Equal`, `Between`, `In`, `Regex`, `IsNumeric`
  and more out of the box; scaffold custom ones with `mix ussd.gen.decision`.
- **Conditional branching** — `Ussd.Action` modules decide the next state at runtime (e.g.
  after an HTTP call), for flows that can't be expressed as static transitions.
- **Back navigation** — `back/2` with an automatic per-session history stack, no manual
  bookkeeping.
- **Automatic pagination** — `use Ussd.Pagination` plus `paginate/1` page long listings
  without manual bookkeeping.
- **Response truncation** — `truncate/1` caps how many characters a screen returns, so
  dynamic content can't blow past your gateway's character limit.
- **Resumable sessions** — `use_continuing_state/4` lets a redial pick back up where a
  timed-out session left off, silently or after confirming with the user.
- **Configurators** — group and share repeated setup (response format, exception handling,
  etc.) across entry points.
- **Localized menus** — build menu content from a Gettext backend, with locale persisted
  across a session.
- **Session records with real expiry** — a `Ussd.Record` API (`get`/`set`/`increment`/
  `decrement`/...) for persisting data during a session, backed by a pluggable `Ussd.Cache`
  with actual TTL, swept periodically instead of leaking forever.
- **Encrypted session data** — store sensitive values (PINs, account numbers) encrypted at
  rest via `set_encrypted`/`get_encrypted`.
- **Built-in gateway responses** — ships `Ussd.Response` formatters for Speso,
  Africa's Talking, Nsano, Nalo, Moolre and Arkesel; scaffold your own with
  `mix ussd.gen.response`.
- **Exception handling** — implement `Ussd.ExceptionHandler` to turn an unhandled
  exception into a message the caller sees, instead of a dead session.
- **Flow visualization** — `mix ussd.graph` renders a Mermaid state diagram of a flow from
  its `transition/2`/`back/2`/`terminate/0` declarations.
- **Interactive simulation** — `mix ussd.simulate` lets you walk a flow in the terminal
  like a real handset, no gateway or phone required.
- **Flow linting** — `mix ussd.lint` catches dead ends, broken transitions, duplicate
  matches and unreachable states before they ship.
- **Testing utilities** — a fluent `Ussd.Test` API for asserting screens, context and
  session state across multi-step conversations.
- **Session events** — `:telemetry` events for state entry and session termination, for
  logging or analytics without touching core modules.
- **Mix generators** — scaffold states, actions, responses, decisions, configurators and
  exception handlers with `mix ussd.gen.*`.

## Usage

### Creating states

```
mix ussd.gen.state Welcome
```

generates `lib/my_app/ussd/states/welcome.ex`:

```elixir
defmodule MyApp.Ussd.States.Welcome do
  use Ussd.State

  alias Ussd.Decisions.Fallback

  transition Fallback.new(), to: __MODULE__
  terminate()

  @impl Ussd.State
  def render(_context) do
    Ussd.Menu.build()
    |> Ussd.Menu.line("Welcome")
  end
end
```

### Building a menu and routing between states

```elixir
defmodule MyApp.Ussd.States.Welcome do
  use Ussd.State, initial: true

  alias Ussd.Decisions.Equal

  transition Equal.new("1"), to: MyApp.Ussd.States.Airtime
  transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle

  @impl Ussd.State
  def render(_context) do
    Ussd.Menu.build()
    |> Ussd.Menu.line("Welcome")
    |> Ussd.Menu.line("Select an option")
    |> Ussd.Menu.listing(["Airtime Topup", "Data Bundle", "TV Subscription", "ECG/GWCL"])
    |> Ussd.Menu.line("")
    |> Ussd.Menu.text("Powered by Speso")
  end
end
```

### Running a request

```elixir
defmodule MyAppWeb.UssdController do
  use MyAppWeb, :controller

  def index(conn, params) do
    context = Ussd.Context.new(params["session_id"], params["phone"], params["input"] || "")

    result =
      context
      |> Ussd.build()
      |> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
      |> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
      |> Ussd.run()

    text(conn, result)
  end
end
```

Nothing here is Phoenix-specific — `Ussd.run/1` returns whatever your `Ussd.Response`
formatter produces, so it works the same from a Plug, a `mix run` script, or a test.

### Configuration

```elixir
# config/config.exs
config :ussd,
  namespace: "MyApp.Ussd",           # used by mix ussd.gen.* and mix ussd.lint's auto-discovery
  cache: Ussd.Cache.ETS,              # or your own Ussd.Cache implementation
  cache_sweep_interval: :timer.minutes(1),
  session_ttl: 300,                   # seconds a mid-flow session survives with no further input
  encryption_key: System.fetch_env!("USSD_ENCRYPTION_KEY"), # required for set_encrypted/get_encrypted
  gettext_backend: MyApp.Gettext      # required for Ussd.Menu.trans/4
```

### Testing

```elixir
import Ussd.Test

test "buying airtime" do
  build(MyApp.Ussd.States.Welcome)
  |> start()
  |> assert_see("Welcome")
  |> input("1")
  |> assert_see("Enter amount")
  |> input("5")
  |> assert_terminated()
end
```

## License

MIT. Please see the [license file](LICENSE) for more information.
