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 componentsapple_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:

{: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:

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:

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
<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).

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:

# config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [wallet_passes_sync: 5]

Then enqueue work:

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 to skip:

%{
  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:

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:

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

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.Workeruse 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.

  • Getting Started — installing the library and configuring the PassDataProvider the sync worker depends on.
  • Pass Lifecycle & Updates — the four pass statuses that exclude_statuses filters on, and the rationale for skipping inactive passes during bulk sync.
  • Local Development — the bundled dev sandbox that demonstrates the preview components against live edits.