# WalletPasses

Apple Wallet and Google Wallet pass generation, management, and remote updates for Elixir.

## Features

- **Apple Wallet:** Build signed `.pkpass` bundles, handle device registration callbacks, send silent APNs pushes
- **Google Wallet:** Create/update pass objects and classes, generate "Save to Google Wallet" URLs, handle save/delete callbacks with full `ECv2SigningOnly` signature verification
- **Unified event handling:** One `WalletPasses.EventHandler` behaviour reacts to pass-added / removed / fetched events from both platforms, dispatched asynchronously under supervision
- **Wallet presence query:** `WalletPasses.wallet_presence/1` reports whether a pass is currently saved on either platform
- **Platform-agnostic data model:** `PassData` struct for content, separate `Apple.Visual` / `Google.Visual` for platform-specific styling
- **Theme helper:** Convert shared colors into platform-specific visual configs
- **QR code generation:** SVG and PNG output
- **Ecto persistence:** Separate per-platform tables with migration generator
- **Telemetry:** `:telemetry` events on every Apple/Google API call, push notification, pkpass build, save URL JWT, callback verification, and event-handler dispatch
- **Optional add-ons:** LiveView preview components, Oban background sync worker

## Installation

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

    def deps do
      [
        {:wallet_passes, "~> 0.6"},
      ]
    end

Generate and run the database migrations:

    $ mix wallet_passes.gen.migration
    $ mix ecto.migrate

Upgrading from `0.5.x`? `0.6.0` adds a `wallet_passes_google_callbacks` audit table. Re-run `mix wallet_passes.gen.migration` and migrate — the generator emits only the new migration files; existing tables are untouched.

## Configuration

    # config/config.exs
    config :wallet_passes,
      repo: MyApp.Repo,
      pass_data_provider: MyApp.WalletPassProvider,
      apple_pass_type_id: "pass.com.example.mypass",
      apple_web_service_url: "https://yourdomain.com/passes/apple",
      google_callback_url: "https://yourdomain.com/passes/google/callback",
      event_handler: MyApp.WalletEventHandler

    # config/runtime.exs
    config :wallet_passes,
      apple_team_id: System.get_env("APPLE_TEAM_ID"),
      apple_pass_type_cert: System.get_env("APPLE_PASS_TYPE_CERT"),
      apple_pass_type_key: System.get_env("APPLE_PASS_TYPE_KEY"),
      apple_wwdr_cert: System.get_env("APPLE_WWDR_CERT"),
      google_issuer_id: System.get_env("GOOGLE_WALLET_ISSUER_ID"),
      google_service_account_json: System.get_env("GOOGLE_WALLET_SERVICE_ACCOUNT_JSON")

Certificate/key values accept file paths, PEM strings, or base64-encoded values.

`:google_callback_url` and `:event_handler` are both optional. Without `:google_callback_url`, no `callbackOptions` is registered on the Google class object and Google won't send save/delete callbacks. Without `:event_handler`, lifecycle events fire but are silently ignored.

## Quick Start

### 1. Implement the PassDataProvider

The library needs to look up pass data autonomously (e.g., when Apple requests an updated pass). Implement the behaviour:

    defmodule MyApp.WalletPassProvider do
      @behaviour WalletPasses.PassDataProvider

      @impl true
      def build_pass_data(serial_number) do
        case MyApp.find_by_serial(serial_number) do
          nil -> {:error, :not_found}
          record ->
            {:ok, %{
              pass_data: %WalletPasses.PassData{
                serial_number: serial_number,
                event_name: record.event_name,
                holder_name: record.holder_name,
                primary_fields: [{"name", "Name", record.holder_name}],
                # ... more fields
              },
              apple: %WalletPasses.Apple.Visual{
                background_color: "#1A1A1A",
                foreground_color: "#FFFFFF",
                label_color: "#D4A843",
                icon_path: "/path/to/icon.png",
              },
              google: %WalletPasses.Google.Visual{
                background_color: "#1A1A1A",
                logo_uri: "https://example.com/logo.png",
              },
            }}
        end
      end
    end

### 2. Generate passes

    # Build an Apple .pkpass
    {:ok, pkpass_binary} = WalletPasses.build_apple_pass(pass_data, apple_visual)

    # Get a Google Wallet save URL
    {:ok, url} = WalletPasses.google_save_url(pass_data, google_visual)

### 3. Mount the callback routers

Apple devices register with your server for push notifications and pull updated passes. Google's servers POST signed callbacks when users save or remove a pass. Mount both routers in your Phoenix app, **outside** any CSRF-protected pipeline (neither sends a CSRF token):

    # router.ex
    forward "/passes/apple", WalletPasses.Apple.Router
    forward "/passes/google", WalletPasses.Google.Router

The Google Router endpoint is `POST /callback`, so the full URL Google will hit is whatever you set `:google_callback_url` to (e.g. `https://yourdomain.com/passes/google/callback`). Every callback is verified against Google's published `ECv2SigningOnly` keys before persistence — no shared secrets, no per-request signing setup on your side.

### 4. Send push updates

    WalletPasses.notify_apple_devices("SERIAL-NUMBER")

### 5. React to pass lifecycle events

Implement the `WalletPasses.EventHandler` behaviour to react to passes being added, removed, or fetched on either platform:

    defmodule MyApp.WalletEventHandler do
      @behaviour WalletPasses.EventHandler

      @impl true
      def on_pass_added(serial, :google, _meta) do
        MyApp.Orders.mark_saved_to_wallet(serial)
      end

      def on_pass_added(serial, :apple, %{device_library_id: device, push_token: token}) do
        MyApp.Telemetry.track_apple_register(serial, device, token)
      end

      @impl true
      def on_pass_removed(serial, :google, _meta) do
        # Definitive: user removed the pass from their Google Wallet.
        MyApp.Orders.mark_pass_removed(serial)
      end

      def on_pass_removed(_serial, :apple, _meta) do
        # Apple unregister fires on push-token rotation, app uninstall, OR genuine removal.
        # Treat as a "device unreachable" signal, not an authoritative "user deleted" signal.
        :ok
      end
    end

Wire it in:

    config :wallet_passes, :event_handler, MyApp.WalletEventHandler

Callbacks run asynchronously under a `Task.Supervisor` so a slow handler can never extend Apple's iOS response time or Google's callback timeout. Exceptions are captured, logged, and reported via telemetry. All three callbacks (`on_pass_added`, `on_pass_removed`, `on_pass_fetched`) are optional — implement only what you care about.

If `:event_handler` is configured but the module exports none of the optional callbacks (typo, missing `@behaviour`), a one-time warning is logged at boot.

### 6. Query current wallet presence

    case WalletPasses.wallet_presence("SERIAL-NUMBER") do
      %{apple: true, google: true}   -> "Saved on both"
      %{apple: true, google: _}      -> "Saved on Apple"
      %{apple: false, google: true}  -> "Saved on Google"
      %{apple: false, google: false} -> "Removed from Google"
      %{apple: false, google: nil}   -> "Not yet saved"
    end

`:google` is `boolean() | nil`. `nil` means no callback has been recorded yet (either the pass was never saved, or `:google_callback_url` isn't configured) — distinct from `false`, which means Google explicitly told us the pass was deleted. `:apple` is "at least one device is reachable for push" — see the `on_pass_removed/3` caveat above for why it isn't authoritative on its own.

## Theme Helper

Use the `Theme` struct to share colors across platforms:

    theme = %WalletPasses.Theme{
      background_color: "#1A1A1A",
      foreground_color: "#FFFFFF",
      label_color: "#D4A843",
      logo_text: "My Event",
    }

    apple_visual = theme
      |> WalletPasses.Theme.to_apple_visual()
      |> struct!(icon_path: "/path/to/icon.png", strip_image_path: "/path/to/strip.png")

    google_visual = theme
      |> WalletPasses.Theme.to_google_visual()
      |> struct!(logo_uri: "https://example.com/logo.png", hero_image_uri: "https://example.com/hero.png")

## NFC Passes

### Apple Wallet (VAS Protocol)

Add NFC fields to your `PassData` to enable tap-to-identify:

    pass_data = PassData.new(
      serial_number: "MEMBER-001",
      nfc_message: "member-id:MEMBER-001",
      nfc_encryption_public_key: "MDkwEwYH...",  # Base64 X.509 ECDH P-256 public key
      nfc_requires_authentication: false,
      # ... other fields
    )

Both `nfc_message` and `nfc_encryption_public_key` are required -- if either is nil, the NFC dictionary is omitted from the pass.

To generate the keypair in the format Apple expects (PKCS#8 private key, compressed-point SPKI public key, base64-encoded), run:

    $ mix wallet_passes.gen.apple_nfc_key

This writes three files into `./nfc_keys/` (override with a path argument). Hand `nfc_private.pem` to your VAS reader vendor and paste the contents of `nfc_public.b64` into `:nfc_encryption_public_key`. Requires `openssl` on `PATH`.

**Note:** Apple NFC passes require a special entitlement from Apple. Apply at [developer.apple.com/contact/passkit](https://developer.apple.com/contact/passkit/).

### Google Wallet (Smart Tap)

Set `nfc_message` on the pass data (used as the Smart Tap redemption value), and enable Smart Tap on the class:

    pass_data = PassData.new(
      serial_number: "MEMBER-001",
      nfc_message: "REDEEM-MEMBER-001",
      # ... other fields
    )

    # When creating the class, enable Smart Tap:
    WalletPasses.Google.Api.create_or_update_class(%{
      id: "loyalty_class",
      issuer_name: "My Store",
      event_name: "Loyalty Card",
      enable_smart_tap: true,
      redemption_issuers: ["YOUR_REDEMPTION_ISSUER_ID"],
    })

**Note:** Google Smart Tap requires partner approval. Contact Google Wallet support to enable Smart Tap for your issuer account.

## Optional Add-ons

### Preview Components (Phoenix LiveView)

Add `{:phoenix_live_view, "~> 1.0"}` to your deps, then:

    import WalletPasses.Preview.Components

    <.apple_pass_preview pass_json={@apple_json} qr_svg={@qr_svg} />
    <.google_pass_preview pass_object={@google_obj} qr_svg={@qr_svg} />

### Background Sync (Oban)

Add `{:oban, "~> 2.18"}` to your deps, then:

    # Sync specific passes
    WalletPasses.Sync.sync(["SERIAL-1", "SERIAL-2"])

    # Sync all passes in the database
    WalletPasses.Sync.sync_all()

## Development

A bundled Phoenix app at `dev/wallet_passes_dev/` provides a visual sandbox for working on the library — live pass previews, editable form inputs, and a mock API activity log. No real Apple/Google credentials needed.

    cd dev/wallet_passes_dev
    mix setup        # deps, db, migrations, assets
    mix phx.server   # http://localhost:4000

See [`dev/README.md`](dev/README.md) for details.

## Why not passbook?

The [`passbook`](https://hex.pm/packages/passbook) (ex_passbook) package is the only other Elixir library for `.pkpass` generation. However:

- **Missing `authenticationToken` support** -- `passbook` doesn't support the `authenticationToken` field, which is required for the pass update lifecycle (Apple devices use it to authenticate callback requests)
- **URL camelization bug** -- `passbook` has a known bug that mis-cases fields containing "url"
- **Full lifecycle** -- This library owns the entire pass lifecycle (generation, callbacks, push updates, Google Wallet API) rather than just `.pkpass` building
- **No runtime dependency on OpenSSL** -- This library uses a pure Erlang PKCS#7 implementation for `.pkpass` signing, while `passbook` shells out to `openssl smime`

## System Requirements

- **PostgreSQL** -- required for pass persistence (via Ecto)

## License

MIT -- see [LICENSE](LICENSE) for details.
