# Add-ons
`wallet_passes` ships two opt-in features behind optional dependencies:
LiveView preview components for rendering passes inside your app, and an
Oban-backed background sync worker for keeping passes in step with your
domain data. Neither is required to issue or update passes — they're
tools for development and operations.
## Overview
- **LiveView preview components** — `apple_pass_preview/1` and
`google_pass_preview/1` render a styled pass card from already-built
`pass.json` / Google object JSON.
- **Oban `Sync.Worker`** — given a list of serial numbers, re-PATCHes
each pass's Google object via your `PassDataProvider` and sends
silent APNs pushes to every registered Apple device.
`WalletPasses.Sync.sync/1` and `WalletPasses.Sync.sync_all/0` are the
convenience entry points.
### The optional-deps pattern
In `mix.exs`, both extras are declared `optional: true`:
```elixir
{:phoenix_live_view, "~> 1.0", optional: true},
{:oban, "~> 2.18", optional: true},
```
`optional: true` means Hex resolves the dep when the consuming app
provides it, but won't pull it in automatically. The library wraps each
module in `if Code.ensure_loaded?(Phoenix.LiveView) do ... end` /
`if Code.ensure_loaded?(Oban) do ... end`, so
`WalletPasses.Preview.Components`, `WalletPasses.Sync`, and
`WalletPasses.Sync.Worker` are only compiled when the corresponding dep
is present.
To use either add-on, declare the dep in your own app:
```elixir
defp deps do
[
{:wallet_passes, "~> 0.8"},
{:phoenix_live_view, "~> 1.0"}, # for preview components
{:oban, "~> 2.18"}, # for background sync
]
end
```
Calling `WalletPasses.Sync.sync(...)` without Oban raises
`UndefinedFunctionError` — the module isn't defined. Same for the
preview components.
## LiveView Preview Components
`WalletPasses.Preview.Components` exposes two function components that
take *already-built* pass JSON and render a styled card. They don't talk
to Apple or Google — they read the same data structures the library
produces.
### Quick example
In a LiveView, build the JSON shapes the components consume and render:
```elixir
import WalletPasses.Preview.Components
alias WalletPasses.{Apple, Google, QR}
def mount(_params, _session, socket) do
{:ok, pass_data, apple_visual, google_visual} = load_pass()
{:ok,
assign(socket,
apple_json: Apple.Builder.build_pass_json(pass_data, apple_visual, "tok"),
google_obj: Google.Api.build_pass_object(pass_data, google_visual),
qr_svg: QR.svg(pass_data.serial_number)
)}
end
```
```heex
<div class="grid grid-cols-2 gap-6">
<.apple_pass_preview pass_json={@apple_json} qr_svg={@qr_svg} />
<.google_pass_preview pass_object={@google_obj} qr_svg={@qr_svg} />
</div>
```
The components use Tailwind / DaisyUI class names. If your app uses
neither, wrap them in your own styling — the markup carries no inline
styles outside of the colors pulled from the pass itself.
### Component assigns
`apple_pass_preview/1` takes `:pass_json` (the map from
`Apple.Builder.build_pass_json/3`) and `:qr_svg` (inline SVG markup from
`WalletPasses.QR.svg/1`). `google_pass_preview/1` takes `:pass_object`
(from `Google.Api.build_pass_object/2`) and `:qr_svg`. Both assigns are
`required: true`. Pass `qr_svg: ""` to omit the QR.
### Which fields render
`apple_pass_preview/1` detects the pass type by which structure key
(`boardingPass`, `storeCard`, `coupon`, `generic`, `eventTicket`) is
present and renders `logoText`, `description`, the first `primaryFields`
entry as the headline, all `secondaryFields` and `auxiliaryFields` as
labeled columns, `backgroundColor` / `foregroundColor` / `labelColor` as
inline styles, the QR SVG, `barcode.message`, and a "Back of pass"
card listing `backFields` if any.
`google_pass_preview/1` mirrors that: `header.defaultValue.value` and a
derived type label, the holder name (`ticketHolderName` /
`passengerName` / `accountName`) as the headline, each `textModulesData`
entry as a labeled row, `hexBackgroundColor`, the QR SVG, and
`barcode.value`.
Both components are *approximations* of how Apple Wallet and Google
Wallet render passes — they're for layout and content review, not
pixel-accurate previews.
### When to use them
**Development.** The bundled sandbox at `dev/wallet_passes_dev/` uses
these components for live previews while editing pass data. Internal
tools — pass designers, fixture browsers, QA checklists — are the
sweet spot.
**Customer-facing previews.** Stable enough for an "is this right?"
confirmation step before a save. Wrap them in your own card chrome and
they pass for production UI.
**Not for validating the bundle.** A successful render says nothing
about whether the `.pkpass` will pass signature verification or whether
the Google object will round-trip through the Wallet API. For that,
inspect the actual artefact (see
[Local Development](local-development.md)).
## Background Sync (Oban)
`WalletPasses.Sync.Worker` is an `Oban.Worker` that re-issues passes
across both platforms in one job. `WalletPasses.Sync` exposes two entry
points: `sync/1` for a specific list of serials, and `sync_all/0` for
every pass in the database.
### Quick example
After adding `{:oban, "~> 2.18"}` to your deps, configure a
`:wallet_passes_sync` queue:
```elixir
# config/config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [wallet_passes_sync: 5]
```
Then enqueue work:
```elixir
WalletPasses.Sync.sync(["TICKET-001", "TICKET-002"])
WalletPasses.Sync.sync_all()
```
Both return `{:ok, %Oban.Job{}}` — the work happens inside the worker.
### What the worker does
For each serial in `args["serial_numbers"]`:
1. Looks up the Google pass row, calls your `PassDataProvider.build_pass_data/1`
for fresh `PassData` and a `Google.Visual`, and PATCHes the existing
Google object via `Google.Api.update_object/3` (re-keys it; doesn't
recreate). Failures are logged and counted into `:google_err`.
2. Collects every Apple push token registered for the surviving serials,
dedupes across serials, and fires a single bulk
`Apple.Push.notify_devices/1` call.
The job returns
`{:ok, %{google_ok: N, google_err: N, apple_ok: N, apple_err: N}}`.
The worker is `unique: [states: [:available, :scheduled, :executing]]`
on `:wallet_passes_sync` with `max_attempts: 1`. Back-to-back enqueues
of the *same args* coalesce; a failed sync is not retried (re-enqueue
if you need to).
### `exclude_statuses`
The worker accepts an optional `exclude_statuses` arg listing
[lifecycle statuses](lifecycle.md) to skip:
```elixir
%{
serial_numbers: ["TICKET-001", "VOIDED-002", "TICKET-003"],
exclude_statuses: ["voided", "expired"]
}
|> WalletPasses.Sync.Worker.new()
|> Oban.insert()
```
Values are strings — Oban serializes job args through JSON, which loses
atoms. A pass whose Google `status` matches any excluded value is
dropped from **both** the Google PATCH set and the Apple push set: its
tokens are filtered out before the bulk push fires.
The four supported statuses are `:active`, `:voided`, `:expired`, and
`:completed`. The `sync/1` and `sync_all/0` shortcuts don't accept
`exclude_statuses` — call `Worker.new/1` directly when you need it.
### Scheduling cadence
There's no built-in scheduler — when and how often to sync is up to
your app. Two common patterns:
**On-change syncs.** When data a pass renders changes (seat assignment,
event time, loyalty balance), enqueue a sync for the affected serials.
The `unique` constraint coalesces bursts:
```elixir
def update_seat(serial, new_seat) do
{:ok, _} = update_in_db(serial, new_seat)
WalletPasses.Sync.sync([serial])
end
```
**Periodic via Oban Cron.** For passes that drift on a schedule (e.g.
nightly tier recalculation), use `Oban.Plugins.Cron`:
```elixir
defmodule MyApp.NightlySync do
use Oban.Worker, queue: :default
def perform(_job) do
WalletPasses.Sync.Worker.new(%{
serial_numbers: MyApp.list_active_serials(),
exclude_statuses: ["voided", "expired", "completed"]
})
|> Oban.insert()
end
end
```
**Avoid blanket `sync_all/0` on tight schedules.** Every sync calls your
`PassDataProvider` and hits the Google Wallet API once per pass. With
thousands of passes, batch by domain key and stagger the work — the
Google Wallet API has per-issuer rate limits, and the worker has no
built-in throttle.
## API Reference
**Preview components**
- `WalletPasses.Preview.Components.apple_pass_preview/1` — required
assigns: `:pass_json` (map), `:qr_svg` (string).
- `WalletPasses.Preview.Components.google_pass_preview/1` — required
assigns: `:pass_object` (map), `:qr_svg` (string).
- `WalletPasses.Apple.Builder.build_pass_json/3` — produces `pass_json`.
- `WalletPasses.Google.Api.build_pass_object/2` — produces `pass_object`.
- `WalletPasses.QR.svg/1` — produces `qr_svg` from the barcode string.
**Background sync**
- `WalletPasses.Sync.sync/1` — `(serials :: [String.t()]) :: {:ok, Oban.Job.t()}`.
- `WalletPasses.Sync.sync_all/0` — enqueues a job covering every
persisted Apple/Google serial (deduped).
- `WalletPasses.Sync.Worker` — `use Oban.Worker`. Its `new/1` accepts
`:serial_numbers` (required, list of strings) and `:exclude_statuses`
(optional, list of `"active" | "voided" | "expired" | "completed"`).
`perform/1` returns
`{:ok, %{google_ok: integer, google_err: integer, apple_ok: integer, apple_err: integer}}`.
Queue: `:wallet_passes_sync`. `max_attempts: 1`. Unique across
`:available`, `:scheduled`, and `:executing` states.
## Related guides
- [Getting Started](getting-started.md) — installing the library and
configuring the `PassDataProvider` the sync worker depends on.
- [Pass Lifecycle & Updates](lifecycle.md) — the four pass statuses
that `exclude_statuses` filters on, and the rationale for skipping
inactive passes during bulk sync.
- [Local Development](local-development.md) — the bundled dev sandbox
that demonstrates the preview components against live edits.