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