defmodule YapilyClient do @moduledoc """ Official Elixir client for the Yapily Open Banking API v12. Connect to 2,000+ banks across the UK and Europe through a single integration. ## Installation ```elixir # mix.exs {:yapily_client, "~> 1.0"} ``` Set credentials at runtime (never hard-code them): ```elixir # config/runtime.exs config :yapily_client, app_key: System.fetch_env!("YAPILY_APP_KEY"), app_secret: System.fetch_env!("YAPILY_APP_SECRET") ``` ## Quick start config = YapilyClient.Config.new!( app_key: System.fetch_env!("YAPILY_APP_KEY"), app_secret: System.fetch_env!("YAPILY_APP_SECRET") ) # List banks {:ok, institutions} = YapilyClient.Institutions.list(config) # Start account authorisation {:ok, auth} = YapilyClient.Authorisations.create_account(config, %{ institution_id: "monzo", application_user_id: "user-123", callback: "https://myapp.com/callback", feature_scope_list: ["ACCOUNTS", "TRANSACTIONS"], one_time_token: true }) # Redirect user to: auth.authorisation_url # Exchange token after callback {:ok, consent} = YapilyClient.Consents.exchange_one_time_token(config, token) # Read accounts {:ok, accounts} = YapilyClient.Accounts.list(config, consent.id) # Stream transactions YapilyClient.Transactions.stream(config, consent.id, account_id) |> Stream.filter(&(&1.currency == "GBP")) |> Enum.take(100) # Make a payment {:ok, payment} = YapilyClient.Payments.create(config, consent_token, %{ type: YapilyClient.payment_type(:domestic), payment_idempotency_id: YapilyClient.idempotency_key(), amount: 100.00, currency: "GBP", recipient: %{ name: "Jane Smith", account_identifications: [ %{type: "SORT_CODE", identification: "200000"}, %{type: "ACCOUNT_NUMBER", identification: "55779911"} ] }, reference: "Invoice-001" }) ## Services | Module | Description | |--------|-------------| | `YapilyClient.Institutions` | List and inspect supported banks | | `YapilyClient.Accounts` | Account list and detail | | `YapilyClient.Transactions` | History — `list/4`, `list_all/4`, `stream/4` | | `YapilyClient.Payments` | Domestic, scheduled, periodic, international | | `YapilyClient.BulkPayments` | Batch payment initiation | | `YapilyClient.Consents` | Lifecycle — exchange, extend, delete | | `YapilyClient.Authorisations` | 14 flows — redirect, embedded, pre-auth | | `YapilyClient.FinancialData` | Balances, direct debits, statements, identity | | `YapilyClient.Users` | PSU management | | `YapilyClient.VRP` | Variable Recurring Payments | | `YapilyClient.Notifications` | Event subscriptions | | `YapilyClient.DataPlus` | Transaction enrichment | | `YapilyClient.HostedPages` | Yapily-hosted UIs | | `YapilyClient.Constraints` | Per-institution rules | | `YapilyClient.ApplicationManagement` | App and sub-app management | | `YapilyClient.Webhooks` | Registration and secret rotation | | `YapilyClient.Beneficiaries` | App + user beneficiaries (VoP) | | `YapilyClient.Validate` | Account ownership verification | | `YapilyClient.ConsentPoller` | Fibonacci back-off polling | | `YapilyClient.Webhook` | HMAC-SHA256 signature verification | ## Error handling case YapilyClient.Accounts.list(config, token) do {:ok, accounts} -> accounts {:error, err} when YapilyClient.Error.not_found?(err) -> handle_not_found() {:error, err} when YapilyClient.Error.vop_rejected?(err) -> handle_vop_failure() {:error, %YapilyClient.Error.APIError{status: s, trace_id: t}} -> Logger.error("API \#{s} trace=\#{t}") {:error, %YapilyClient.Error.EnhancedAPIError{issues: issues}} -> Enum.each(issues, &Logger.error("[code \#{&1.code}] \#{&1.message}")) {:error, %YapilyClient.Error.ValidationError{field: f, message: m}} -> {:error, "\#{f}: \#{m}"} end ## Consent polling case YapilyClient.ConsentPoller.wait_for_authorisation(config, consent_id) do {:ok, %{status: "AUTHORIZED"} = consent} -> proceed(consent) {:ok, %{status: status}} -> handle_failure(status) {:error, :timed_out} -> show_timeout() end ## Webhook verification case YapilyClient.Webhook.verify(raw_body, secret, signature_header) do :ok -> process_event(event) {:error, :invalid_signature} -> send_resp(conn, 401, "unauthorized") {:error, :missing_signature} -> send_resp(conn, 400, "missing signature") end """ @version "1.0.0" @doc "Returns the SDK version." @spec version() :: String.t() def version, do: @version @doc "Returns the Yapily API version this SDK targets." @spec api_version() :: String.t() def api_version, do: "12.0.0" @doc """ Returns `key` if non-empty, otherwise generates a unique idempotency key. Always provide an idempotency key when creating payments to prevent duplicate submissions on network retry. ## Examples YapilyClient.idempotency_key("order-123") # => "order-123" YapilyClient.idempotency_key() # => "idem-" YapilyClient.idempotency_key("") # => "idem-" """ @spec idempotency_key(String.t()) :: String.t() def idempotency_key(key \\ "") do if key != "" and String.trim(key) != "", do: key, else: "idem-#{System.unique_integer([:positive, :monotonic])}" end @doc """ Returns the Yapily API payment type string for the given atom. | Atom | String | |------|--------| | `:domestic` | `"DOMESTIC_PAYMENT"` | | `:domestic_scheduled` | `"DOMESTIC_SCHEDULED_PAYMENT"` | | `:domestic_periodic` | `"DOMESTIC_PERIODIC_PAYMENT"` | | `:international` | `"INTERNATIONAL_PAYMENT"` | | `:international_scheduled` | `"INTERNATIONAL_SCHEDULED_PAYMENT"` | | `:international_periodic` | `"INTERNATIONAL_PERIODIC_PAYMENT"` | """ @spec payment_type( :domestic | :domestic_scheduled | :domestic_periodic | :international | :international_scheduled | :international_periodic ) :: String.t() def payment_type(:domestic), do: "DOMESTIC_PAYMENT" def payment_type(:domestic_scheduled), do: "DOMESTIC_SCHEDULED_PAYMENT" def payment_type(:domestic_periodic), do: "DOMESTIC_PERIODIC_PAYMENT" def payment_type(:international), do: "INTERNATIONAL_PAYMENT" def payment_type(:international_scheduled), do: "INTERNATIONAL_SCHEDULED_PAYMENT" def payment_type(:international_periodic), do: "INTERNATIONAL_PERIODIC_PAYMENT" @doc """ Returns the Yapily frequency string for a periodic payment. | Atom | String | |------|--------| | `:daily` | `"DAILY"` | | `:every_working_day` | `"EVERY_WORKING_DAY"` | | `:calendar_day` | `"CALENDAR_DAY"` | | `:weekly` | `"WEEKLY"` | | `:every_two_weeks` | `"EVERY_TWO_WEEKS"` | | `:monthly` | `"MONTHLY"` | | `:every_two_months` | `"EVERY_TWO_MONTHS"` | | `:quarterly` | `"QUARTERLY"` | | `:semi_annual` | `"SEMIANNUAL"` | | `:annual` | `"ANNUAL"` | """ @spec frequency( :daily | :every_working_day | :calendar_day | :weekly | :every_two_weeks | :monthly | :every_two_months | :quarterly | :semi_annual | :annual ) :: String.t() def frequency(:daily), do: "DAILY" def frequency(:every_working_day), do: "EVERY_WORKING_DAY" def frequency(:calendar_day), do: "CALENDAR_DAY" def frequency(:weekly), do: "WEEKLY" def frequency(:every_two_weeks), do: "EVERY_TWO_WEEKS" def frequency(:monthly), do: "MONTHLY" def frequency(:every_two_months), do: "EVERY_TWO_MONTHS" def frequency(:quarterly), do: "QUARTERLY" def frequency(:semi_annual), do: "SEMIANNUAL" def frequency(:annual), do: "ANNUAL" @doc "Returns the Yapily consent status string for the given atom." @spec consent_status(:awaiting | :authorized | :rejected | :revoked | :failed | :expired) :: String.t() def consent_status(:awaiting), do: "AWAITING_AUTHORIZATION" def consent_status(:authorized), do: "AUTHORIZED" def consent_status(:rejected), do: "REJECTED" def consent_status(:revoked), do: "REVOKED" def consent_status(:failed), do: "FAILED" def consent_status(:expired), do: "EXPIRED" end