Current section
Files
Jump to
Current section
Files
lib/wallet_passes.ex
defmodule WalletPasses do
@moduledoc """
Apple Wallet and Google Wallet pass generation, management, and remote updates.
This module provides convenience functions that orchestrate both platforms.
For platform-specific control, use `WalletPasses.Apple.Builder`,
`WalletPasses.Google.Api`, etc. directly.
## Pass Lifecycle
Passes have a lifecycle status: `:active` (default), `:voided`, `:expired`,
`:completed`. Use `void_pass/1`, `expire_pass/1`, `complete_pass/1`, and
`reactivate_pass/1` to transition. Each function:
* Updates the `status` column on whichever Apple/Google rows exist for the
serial (transactional — the two rows can't diverge from a DB error).
* Patches the Google object's `state` field via the Wallet API
(`ACTIVE`/`INACTIVE`/`EXPIRED`/`COMPLETED`).
* Triggers Apple device push so devices fetch the updated pass content.
Returns `{:ok, %{status: status, apple: :ok | :not_found,
google: :ok | :not_found | {:error, _}}}` even when remote calls fail — the
DB write is the source of truth, and remote failures are surfaced for
caller-driven retry rather than silently rolled back. Returns
`{:error, :not_found}` only when neither an Apple nor a Google row exists.
`reactivate_pass/1` lets a non-active pass return to `:active`. The library
does not enforce transition validity; consumers wanting a state machine
should wrap these calls in their own policy layer.
For Apple devices to render lifecycle states differently, the
`PassDataProvider.build_pass_data/1` implementation should inspect
`WalletPasses.Schema.get_pass_status/1` (or call
`WalletPasses.PassDataProvider.apply_status_decoration/2`) before returning
the pass.
The `:expired` status overlaps with Apple's native `expirationDate` field
in the `.pkpass`. Use `expire_pass/1` for issuer-driven expiry events
(refunded ticket, revoked subscription); use `expirationDate` for
date-based expiry that the OS handles automatically.
## Localization
Pass content can be translated for both platforms via a single `:translations`
map option:
translations = %{
"fr" => %{"Gate" => "Porte", "Section" => "Section"},
"es" => %{"Gate" => "Puerta"}
}
WalletPasses.build_apple_pass(pass_data, visual, translations: translations)
WalletPasses.google_save_url(pass_data, visual, translations: translations)
Apple receives `.lproj/pass.strings` entries inside the `.pkpass` ZIP; Google
receives `LocalizedString` `translatedValues` on the object and class JSON.
The same map drives both. See the
[Localization Guide](guides/localization.md) for the full reference.
"""
alias WalletPasses.Apple
alias WalletPasses.Config
alias WalletPasses.Google
alias WalletPasses.PassData
alias WalletPasses.PassType
alias WalletPasses.Schema
@doc """
Generates an Apple .pkpass binary for a pass.
Creates or retrieves the Apple pass record, then builds the .pkpass bundle.
## Options
* `:translations` — Passed through to `Apple.Builder.build_pkpass/4`. See the
[Localization](guides/localization.md) guide.
* `:localized_images` — Passed through to `Apple.Builder.build_pkpass/4`.
"""
def build_apple_pass(%PassData{} = pass_data, %Apple.Visual{} = visual, opts \\ []) do
with {:ok, apple_pass} <- Schema.get_or_create_apple_pass(pass_data.serial_number) do
Apple.Builder.build_pkpass(pass_data, visual, apple_pass.auth_token, opts)
end
end
@doc """
Generates a "Save to Google Wallet" URL for a pass.
Creates or retrieves the Google pass record, creates/updates the object
on Google's servers, and returns the save URL.
## Options
* `:class_id` — Class ID suffix to use for the object (defaults to the
pass type's standard suffix).
* `:class_config` — Map of class configuration. When provided, ensures
the class exists on Google's servers (idempotent — only created once
per VM lifetime per class). The map's `:id` key defaults to the
`:class_id` opt or to the pass type's standard suffix.
* `:origins` — Passed through to `Google.SaveUrl`.
* `:translations` — Passed through to `Google.Api.build_pass_object/3` for
object-level localization. To localize class-level fields, include
`:translations` inside the `:class_config` map. See the
[Localization](guides/localization.md) guide.
"""
def google_save_url(%PassData{} = pass_data, %Google.Visual{} = visual, opts \\ []) do
{class_id, opts} = Keyword.pop(opts, :class_id)
{class_config, opts} = Keyword.pop(opts, :class_config)
{translations, save_opts} = Keyword.pop(opts, :translations)
builder_opts = [class_id: class_id, translations: translations]
with :ok <- maybe_ensure_class(class_config, class_id, pass_data.pass_type),
{:ok, google_pass} <-
Schema.get_or_create_google_pass(pass_data.serial_number, pass_data.pass_type),
{:ok, object_id} <- Google.Api.create_object(pass_data, visual, builder_opts) do
Schema.update_google_object_id(google_pass, object_id)
pass_object = Google.Api.build_pass_object(pass_data, visual, builder_opts)
Google.SaveUrl.url(pass_object, save_opts)
end
end
@doc "Sends Apple push notifications for a pass to trigger refresh on devices."
def notify_apple_devices(serial_number) when is_binary(serial_number) do
tokens = Schema.list_push_tokens_for_serial(serial_number)
Apple.Push.notify_devices(tokens)
end
@doc """
Updates a Google Wallet pass object on Google's servers.
## Options
* `:class_id` — Pass-through to `Google.Api.update_object`.
* `:class_config` — Map of class configuration. When provided, ensures
the class exists on Google's servers (idempotent).
* `:translations` — Passed through to `Google.Api.build_pass_object/3`.
See the [Localization](guides/localization.md) guide.
"""
def update_google_pass(%PassData{} = pass_data, %Google.Visual{} = visual, opts \\ []) do
{class_id, opts} = Keyword.pop(opts, :class_id)
{class_config, opts} = Keyword.pop(opts, :class_config)
{translations, update_opts} = Keyword.pop(opts, :translations)
with :ok <- maybe_ensure_class(class_config, class_id, pass_data.pass_type) do
case Schema.get_google_pass(pass_data.serial_number) do
nil ->
{:error, :not_found}
google_pass when is_nil(google_pass.object_id) ->
{:error, :no_object_id}
google_pass ->
builder_opts =
update_opts
|> Keyword.put(:class_id, class_id)
|> Keyword.put(:translations, translations)
Google.Api.update_object(
pass_data,
visual,
google_pass.object_id,
builder_opts
)
end
end
end
@doc """
Returns wallet presence for a pass across both platforms.
* `:apple` — `true` when the pass has at least one device registration.
Note: this is a "reachable for push" signal, not a "user has the pass"
signal — see `c:WalletPasses.EventHandler.on_pass_removed/3` for the
caveats around iOS push-token rotation and app uninstall.
* `:google` — `true` when the latest recorded callback for the pass is
`save`, `false` if `del`, and `nil` if no callback has been received
(either the pass was never saved, or the consumer doesn't have
`:google_callback_url` configured).
"""
@spec wallet_presence(String.t()) :: %{apple: boolean(), google: boolean() | nil}
def wallet_presence(serial_number) when is_binary(serial_number) do
%{
apple: Schema.has_device_registrations?(serial_number),
google: google_wallet_presence(serial_number),
}
end
@type lifecycle_result :: %{
status: :active | :voided | :expired | :completed,
apple: :ok | :not_found,
google: :ok | :not_found | {:error, term()},
}
@doc """
Voids a pass. Updates the DB status for whichever Apple/Google rows exist for
the serial, patches Google's object state to `INACTIVE`, and pushes Apple
devices so they refetch the pass.
Returns `{:ok, lifecycle_result()}` even when remote calls fail — the DB write
is the source of truth and is never rolled back. Per-platform outcomes are
reported in the result map. Returns `{:error, :not_found}` only when neither
an Apple nor a Google row exists for the serial.
Idempotent.
"""
@spec void_pass(String.t()) :: {:ok, lifecycle_result()} | {:error, :not_found}
def void_pass(serial_number), do: transition(serial_number, :voided)
@doc "Marks a pass as expired on all platforms. See `void_pass/1` for semantics."
@spec expire_pass(String.t()) :: {:ok, lifecycle_result()} | {:error, :not_found}
def expire_pass(serial_number), do: transition(serial_number, :expired)
@doc "Marks a pass as completed (used/redeemed) on all platforms. See `void_pass/1`."
@spec complete_pass(String.t()) :: {:ok, lifecycle_result()} | {:error, :not_found}
def complete_pass(serial_number), do: transition(serial_number, :completed)
@doc "Reactivates a non-active pass. See `void_pass/1` for semantics."
@spec reactivate_pass(String.t()) :: {:ok, lifecycle_result()} | {:error, :not_found}
def reactivate_pass(serial_number), do: transition(serial_number, :active)
defp transition(serial_number, target) when is_binary(serial_number) do
apple_pass = Schema.get_apple_pass(serial_number)
google_pass = Schema.get_google_pass(serial_number)
if is_nil(apple_pass) and is_nil(google_pass) do
{:error, :not_found}
else
{:ok, db_result} = Schema.set_pass_status(serial_number, target)
google_outcome = handle_google(google_pass, serial_number, target, db_result.google)
apple_outcome = handle_apple(serial_number, db_result.apple)
{:ok, %{status: target, apple: apple_outcome, google: google_outcome}}
end
end
defp handle_google(nil, _serial, _target, _db), do: :not_found
defp handle_google(%{object_id: nil}, _serial, _target, _db), do: :not_found
defp handle_google(_google_pass, _serial, _target, :not_found), do: :not_found
defp handle_google(google_pass, serial_number, target, :ok) do
case resolve_pass_type(google_pass, serial_number) do
{:ok, pass_type} ->
google_state = google_state_for(target)
case Google.Api.update_object_state(pass_type, google_pass.object_id, google_state) do
{:ok, %{status: s}} when s in 200..299 -> :ok
{:ok, %{status: s, body: body}} -> {:error, {s, body}}
{:error, _} = err -> err
end
{:error, _} = err ->
err
end
end
defp handle_apple(_serial, :not_found), do: :not_found
defp handle_apple(serial_number, :ok) do
_ = notify_apple_devices(serial_number)
:ok
end
defp google_state_for(:active), do: "ACTIVE"
defp google_state_for(:voided), do: "INACTIVE"
defp google_state_for(:expired), do: "EXPIRED"
defp google_state_for(:completed), do: "COMPLETED"
defp resolve_pass_type(%{pass_type: pt}, _serial) when is_binary(pt) do
{:ok, String.to_existing_atom(pt)}
end
defp resolve_pass_type(%{pass_type: nil}, serial_number) do
provider = Config.pass_data_provider()
case provider.build_pass_data(serial_number) do
{:ok, %{pass_data: %{pass_type: pt}}} when is_atom(pt) ->
# Write-through: backfill the column so future transitions skip the lookup.
{:ok, _} = Schema.get_or_create_google_pass(serial_number, pt)
{:ok, pt}
{:ok, _} ->
{:error, :pass_type_missing_from_provider}
{:error, reason} ->
{:error, {:pass_type_lookup_failed, reason}}
end
end
defp google_wallet_presence(serial_number) do
case Schema.latest_google_callback(serial_number) do
nil -> nil
%{event_type: "save"} -> true
%{event_type: "del"} -> false
end
end
defp maybe_ensure_class(nil, _class_id, _pass_type), do: :ok
defp maybe_ensure_class(class_config, class_id, pass_type) when is_map(class_config) do
default_id = class_id || PassType.google_class_suffix(pass_type)
config = Map.put_new(class_config, :id, default_id)
Google.Api.ensure_class(config, pass_type)
end
end