diff --git a/config/config.exs b/config/config.exs index 8c8e30e4..127de8ff 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,7 +18,7 @@ config :spendable, config :spendable, Oban, repo: Spendable.Repo, - queues: [banks: 5] + queues: [banks: 5, notifications: 5] config :spendable, Spendable.Repo, migration_primary_key: [type: :text], @@ -80,6 +80,12 @@ config :spendable, Spendable.Accounts.Clients.Apple, base_url: "https://appleid.apple.com", audiences: ["fiftysevenmedia.Spendable"] +# An APNs topic is the bundle id. The signing key, its id and the team id come from runtime.exs; +# without them the client answers `{:error, :not_configured}` and nothing else changes. +config :spendable, Spendable.Accounts.Clients.Apns, + base_url: "https://api.push.apple.com", + topic: "fiftysevenmedia.Spendable" + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/runtime.exs b/config/runtime.exs index 2f6e2ed1..92602795 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -12,6 +12,18 @@ defmodule Secret do System.get_env(name, non_prod_default) end end + + # For credentials the app runs without. A missing file is the answer, not a failure to boot. + def read(name) do + if config_env() == :prod do + case File.read("/etc/secrets/" <> name) do + {:ok, value} -> value + {:error, :enoent} -> nil + end + else + System.get_env(name) + end + end end config :spendable, Spendable.Banks.Clients.Plaid, @@ -22,9 +34,17 @@ config :ueberauth, Ueberauth.Strategy.Google.OAuth, client_id: Secret.read!("GOOGLE_CLIENT_ID"), client_secret: Secret.read!("GOOGLE_CLIENT_SECRET") +# Both pin values that config/test.exs sets for itself, which these would otherwise blank out. # An OAuth client id is a public identifier, not a secret, so the iOS one is a plain env var. -# Test pins its own audience in config/test.exs, which this would otherwise blank out. +# Push notifications are optional: with no signing key the client refuses to send and everything +# else is unchanged, so a machine without a `.p8` still runs. if config_env() != :test do + config :spendable, Spendable.Accounts.Clients.Apns, + base_url: System.get_env("APNS_BASE_URL", "https://api.push.apple.com"), + key_id: Secret.read("APNS_KEY_ID"), + private_key: Secret.read("APNS_PRIVATE_KEY"), + team_id: Secret.read("APNS_TEAM_ID") + config :spendable, Spendable.Accounts.Clients.Google, base_url: "https://www.googleapis.com", audiences: diff --git a/config/test.exs b/config/test.exs index 98492525..018ed2ec 100644 --- a/config/test.exs +++ b/config/test.exs @@ -37,6 +37,19 @@ config :spendable, Spendable.Accounts.Clients.Google, base_url: "https://www.googleapis.com", audiences: ["spendable-ios.apps.googleusercontent.com"] +# A throwaway P-256 key, here rather than in test/support because config is read before anything +# is compiled. It signs nothing that leaves the test run - Tesla is mocked. +config :spendable, Spendable.Accounts.Clients.Apns, + key_id: "ABC1234567", + team_id: "DEF8901234", + private_key: """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgqSgwFjvegbhHedz/ + 5v5WvWxfgUEv8GI5uci7vhcAJvGhRANCAATFnAuzQDfLCiZ4ei706S5hE/tCwwUw + Pb1xcx7n5+asgl4/cpbFp0N3mrIUi5Vdg/SSncErF+OL18UIlcnaUX3Y + -----END PRIVATE KEY----- + """ + config :tesla, adapter: TeslaMock # Jobs run inline in tests so a sync is asserted on, not waited for. diff --git a/lib/spendable/accounts.ex b/lib/spendable/accounts.ex index b20e2e82..c185bb40 100644 --- a/lib/spendable/accounts.ex +++ b/lib/spendable/accounts.ex @@ -5,10 +5,13 @@ defmodule Spendable.Accounts do defdelegate authenticate_api_token(token), to: Actions.AuthenticateApiToken defdelegate create_api_token(scope, attrs), to: Actions.CreateApiToken + defdelegate deliver_notification(user_id, notification), to: Actions.DeliverNotification defdelegate get_identity(scope, id), to: Actions.GetIdentity defdelegate get_user(id), to: Actions.GetUser defdelegate link_identity(scope, provider, id_token), to: Actions.LinkIdentity defdelegate list_identities(scope), to: Actions.ListIdentities + defdelegate notify_user(scope, notification), to: Actions.NotifyUser + defdelegate register_apns_token(scope, api_token, apns_token), to: Actions.RegisterApnsToken defdelegate revoke_api_token(scope, api_token), to: Actions.RevokeApiToken defdelegate sign_in_with_oauth(provider, id_token), to: Actions.SignInWithOauth defdelegate unlink_identity(scope, identity), to: Actions.UnlinkIdentity diff --git a/lib/spendable/accounts/CONTEXT.md b/lib/spendable/accounts/CONTEXT.md index cdf708fe..1e3f2b6e 100644 --- a/lib/spendable/accounts/CONTEXT.md +++ b/lib/spendable/accounts/CONTEXT.md @@ -12,10 +12,24 @@ _Avoid_: Customer, member, profile How many bank connections a user is allowed to hold at once. _Avoid_: Quota, plan, tier +**API Token**: +One signed-in device's credential. Held per device, so revoking one signs out only that device. +_Avoid_: Session, key + +**Device Token**: +What APNs calls a device, held against the **API Token** it was registered with. +_Avoid_: Push token, APNs token outside the code that talks to Apple + +**Notification**: +One push to a **User**, whatever produced it. A sync that found fifty charges is one of these. +_Avoid_: Alert, which is only its visible half, and message + ## Relationships - A **User** owns every **Budget**, **Transaction** and **Bank Member** in the system - A **User**'s **Bank Limit** caps how many **Bank Members** they may hold +- A **User** holds one **API Token** per device, each carrying at most one **Device Token** +- A **Notification** goes to every **Device Token** a **User** has registered ## Flagged ambiguities diff --git a/lib/spendable/accounts/actions/deliver_notification.ex b/lib/spendable/accounts/actions/deliver_notification.ex new file mode 100644 index 00000000..253428d6 --- /dev/null +++ b/lib/spendable/accounts/actions/deliver_notification.ex @@ -0,0 +1,75 @@ +defmodule Spendable.Accounts.Actions.DeliverNotification do + @moduledoc false + + import Ecto.Query + + alias Spendable.Accounts.Clients.Apns + alias Spendable.Accounts.Schemas.ApiToken + alias Spendable.Repo + + require Logger + + # A token APNs names as dead will never work again, so the row is cleared instead of retried. + @dead_token_reasons ["BadDeviceToken", "DeviceTokenNotForTopic", "Unregistered"] + + @doc """ + Pushes to every device the user has registered. Takes an id rather than a scope because the only + caller is the job queue, which carries ids. + + Every completed sync pushes, count or no count: the silent half is the only signal the app gets + that a sync it asked for has finished. + """ + def deliver_notification(user_id, %{count: count, total: total, alert: alert}) + when is_binary(user_id) do + message = build_message(count, total, alert) + + query = + from api_token in ApiToken, + where: api_token.user_id == ^user_id, + where: not is_nil(api_token.apns_token) + + query + |> Repo.all() + |> Enum.map(&push(&1, message)) + |> Enum.find(:ok, &match?({:error, _reason}, &1)) + end + + defp push(%ApiToken{} = api_token, {payload, headers}) do + case Apns.push(api_token.apns_token, payload, headers) do + {:ok, %Tesla.Env{status: 200}} -> + :ok + + {:ok, %Tesla.Env{body: %{"reason" => reason}}} when reason in @dead_token_reasons -> + Logger.info("APNs rejected a device: #{reason}") + + {:ok, _cleared} = api_token |> ApiToken.changeset(%{apns_token: nil}) |> Repo.update() + + :ok + + {:ok, %Tesla.Env{status: status, body: body}} -> + {:error, "APNs returned #{status}: #{inspect(body)}"} + + {:error, :not_configured} -> + :ok + end + end + + defp build_message(count, total, true) when count > 0 do + noun = if count == 1, do: "transaction", else: "transactions" + amount = total |> Decimal.abs() |> Decimal.round(2) |> Decimal.to_string(:normal) + + payload = %{ + aps: %{ + alert: %{title: "Spendable", body: "#{count} new #{noun} · $#{amount}"}, + "content-available": 1, + sound: "default" + } + } + + {payload, [{"apns-push-type", "alert"}, {"apns-priority", "10"}]} + end + + defp build_message(_count, _total, _alert) do + {%{aps: %{"content-available": 1}}, [{"apns-push-type", "background"}, {"apns-priority", "5"}]} + end +end diff --git a/lib/spendable/accounts/actions/deliver_notification_test.exs b/lib/spendable/accounts/actions/deliver_notification_test.exs new file mode 100644 index 00000000..85fbf92d --- /dev/null +++ b/lib/spendable/accounts/actions/deliver_notification_test.exs @@ -0,0 +1,133 @@ +defmodule Spendable.Accounts.Actions.DeliverNotificationTest do + # Not async: the unconfigured case is the absence of application config, which is global. + use Spendable.DataCase, async: false + + alias Spendable.Accounts + alias Spendable.Accounts.Clients.Apns + alias Spendable.Accounts.Schemas.ApiToken + alias Spendable.Scope + + @device_token String.duplicate("ab", 32) + + setup do + {:ok, user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + scope = Scope.for_user(user) + + {:ok, api_token} = Accounts.create_api_token(scope, %{}) + {:ok, api_token} = Accounts.register_apns_token(scope, api_token, @device_token) + + %{api_token: api_token, user: user} + end + + test "tells the user what landed", %{user: user} do + expect(TeslaMock, :call, fn %{body: body, headers: headers}, _opts -> + send(self(), {:pushed, body, headers}) + + TeslaHelper.response(status: 200) + end) + + assert :ok = + Accounts.deliver_notification(user.id, %{ + count: 3, + total: Decimal.new("-84.21"), + alert: true + }) + + assert_received {:pushed, body, headers} + assert %{"aps" => %{"alert" => %{"body" => "3 new transactions · $84.21"}}} = Jason.decode!(body) + assert {"apns-push-type", "alert"} in headers + assert {"apns-priority", "10"} in headers + end + + test "counts one transaction as one", %{user: user} do + expect(TeslaMock, :call, fn %{body: body}, _opts -> + send(self(), {:pushed, body}) + + TeslaHelper.response(status: 200) + end) + + :ok = + Accounts.deliver_notification(user.id, %{ + count: 1, + total: Decimal.new("-9.5"), + alert: true + }) + + assert_received {:pushed, body} + assert %{"aps" => %{"alert" => %{"body" => "1 new transaction · $9.50"}}} = Jason.decode!(body) + end + + # The app has no other signal that a sync it asked for has finished. + test "wakes the app when a sync found nothing", %{user: user} do + expect(TeslaMock, :call, fn %{body: body, headers: headers}, _opts -> + send(self(), {:pushed, body, headers}) + + TeslaHelper.response(status: 200) + end) + + :ok = Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true}) + + assert_received {:pushed, body, headers} + assert %{"aps" => aps} = Jason.decode!(body) + refute Map.has_key?(aps, "alert") + assert {"apns-push-type", "background"} in headers + assert {"apns-priority", "5"} in headers + end + + test "stays silent about a run that was not the user's to hear about", %{user: user} do + expect(TeslaMock, :call, fn %{body: body}, _opts -> + send(self(), {:pushed, body}) + + TeslaHelper.response(status: 200) + end) + + :ok = + Accounts.deliver_notification(user.id, %{ + count: 240, + total: Decimal.new("-9000"), + alert: false + }) + + assert_received {:pushed, body} + assert %{"aps" => aps} = Jason.decode!(body) + refute Map.has_key?(aps, "alert") + end + + test "drops a device APNs says is gone", %{api_token: api_token, user: user} do + expect(TeslaMock, :call, fn _env, _opts -> + TeslaHelper.response(status: 410, body: %{"reason" => "Unregistered"}) + end) + + assert :ok = Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true}) + + assert %ApiToken{apns_token: nil} = Repo.get(ApiToken, api_token.id) + end + + test "retries an APNs failure that is not the device's fault", %{user: user} do + expect(TeslaMock, :call, fn _env, _opts -> + TeslaHelper.response(status: 503, body: %{"reason" => "ServiceUnavailable"}) + end) + + assert {:error, _reason} = + Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true}) + end + + test "sends nothing to a user with no registered device" do + {:ok, other} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + assert :ok = Accounts.deliver_notification(other.id, %{count: 0, total: Decimal.new(0), alert: true}) + end + + test "does nothing on a machine with no signing key", %{user: user} do + configured = Application.get_env(:spendable, Apns) + + on_exit(fn -> Application.put_env(:spendable, Apns, configured) end) + + Application.put_env(:spendable, Apns, Keyword.delete(configured, :private_key)) + + assert :ok = Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true}) + end +end diff --git a/lib/spendable/accounts/actions/notify_user.ex b/lib/spendable/accounts/actions/notify_user.ex new file mode 100644 index 00000000..c5354306 --- /dev/null +++ b/lib/spendable/accounts/actions/notify_user.ex @@ -0,0 +1,19 @@ +defmodule Spendable.Accounts.Actions.NotifyUser do + @moduledoc false + + alias Spendable.Accounts.Jobs.SendNotification + alias Spendable.Scope + + @doc """ + Queues one notification for a user, whatever it took to produce it - a sync that found fifty + charges is still one push. + + `alert` false sends the silent half only: the app is told the sync finished without the user + being told anything. + """ + def notify_user(%Scope{user: %{id: user_id}}, %{count: count, total: total, alert: alert}) do + %{user_id: user_id, count: count, total: Decimal.to_string(total), alert: alert} + |> SendNotification.new() + |> Oban.insert() + end +end diff --git a/lib/spendable/accounts/actions/notify_user_test.exs b/lib/spendable/accounts/actions/notify_user_test.exs new file mode 100644 index 00000000..f4dedf65 --- /dev/null +++ b/lib/spendable/accounts/actions/notify_user_test.exs @@ -0,0 +1,30 @@ +defmodule Spendable.Accounts.Actions.NotifyUserTest do + use Spendable.DataCase, async: true + + alias Spendable.Accounts + alias Spendable.Accounts.Jobs.SendNotification + alias Spendable.Scope + + setup do + {:ok, user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + %{scope: Scope.for_user(user)} + end + + test "queues one notification for the user", %{scope: scope} do + {:ok, _job} = + Accounts.notify_user(scope, %{count: 3, total: Decimal.new("-84.21"), alert: true}) + + assert_enqueued( + worker: SendNotification, + args: %{user_id: scope.user.id, count: 3, total: "-84.21", alert: true} + ) + end + + test "queues a silent one", %{scope: scope} do + {:ok, _job} = Accounts.notify_user(scope, %{count: 0, total: Decimal.new(0), alert: false}) + + assert_enqueued(worker: SendNotification, args: %{user_id: scope.user.id, alert: false}) + end +end diff --git a/lib/spendable/accounts/actions/register_apns_token.ex b/lib/spendable/accounts/actions/register_apns_token.ex new file mode 100644 index 00000000..05098023 --- /dev/null +++ b/lib/spendable/accounts/actions/register_apns_token.ex @@ -0,0 +1,23 @@ +defmodule Spendable.Accounts.Actions.RegisterApnsToken do + @moduledoc false + + alias Spendable.Accounts.Schemas.ApiToken + alias Spendable.Repo + alias Spendable.Scope + + @doc """ + Registers the device a token was issued to for push. It rides on the token row rather than a + table of its own, so revoking the token stops the pushes with it. + """ + def register_apns_token( + %Scope{user: %{id: user_id}}, + %ApiToken{user_id: user_id} = api_token, + apns_token + ) do + api_token + |> ApiToken.changeset(%{apns_token: apns_token}) + |> Repo.update() + end + + def register_apns_token(%Scope{}, %ApiToken{}, _apns_token), do: {:error, :not_authorized} +end diff --git a/lib/spendable/accounts/actions/register_apns_token_test.exs b/lib/spendable/accounts/actions/register_apns_token_test.exs new file mode 100644 index 00000000..7e177baa --- /dev/null +++ b/lib/spendable/accounts/actions/register_apns_token_test.exs @@ -0,0 +1,58 @@ +defmodule Spendable.Accounts.Actions.RegisterApnsTokenTest do + use Spendable.DataCase, async: true + + alias Spendable.Accounts + alias Spendable.Accounts.Schemas.ApiToken + alias Spendable.Scope + + @device_token String.duplicate("ab", 32) + + setup do + {:ok, user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + scope = Scope.for_user(user) + + {:ok, api_token} = Accounts.create_api_token(scope, %{}) + + %{api_token: api_token, scope: scope} + end + + test "records the device token", %{api_token: api_token, scope: scope} do + assert is_nil(api_token.apns_token) + + assert {:ok, %ApiToken{apns_token: @device_token}} = + Accounts.register_apns_token(scope, api_token, @device_token) + end + + test "replaces the token a device registered before", %{api_token: api_token, scope: scope} do + {:ok, api_token} = Accounts.register_apns_token(scope, api_token, @device_token) + + reissued = String.duplicate("cd", 32) + + assert {:ok, %ApiToken{apns_token: ^reissued}} = + Accounts.register_apns_token(scope, api_token, reissued) + end + + test "rejects a token that is not a device token", %{api_token: api_token, scope: scope} do + not_hex = String.duplicate("xy", 32) + + assert {:error, changeset} = Accounts.register_apns_token(scope, api_token, not_hex) + + assert %{apns_token: ["has invalid format"]} = errors_on(changeset) + end + + test "rejects a token that is too short", %{api_token: api_token, scope: scope} do + assert {:error, changeset} = Accounts.register_apns_token(scope, api_token, "abcd") + + assert %{apns_token: ["should be at least 64 character(s)"]} = errors_on(changeset) + end + + test "refuses another user's token", %{api_token: api_token} do + {:ok, other} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + assert {:error, :not_authorized} = + Accounts.register_apns_token(Scope.for_user(other), api_token, @device_token) + end +end diff --git a/lib/spendable/accounts/clients/apns.ex b/lib/spendable/accounts/clients/apns.ex new file mode 100644 index 00000000..783518d0 --- /dev/null +++ b/lib/spendable/accounts/clients/apns.ex @@ -0,0 +1,57 @@ +defmodule Spendable.Accounts.Clients.Apns do + @moduledoc false + + # APNs rejects a provider token minted more than once every 20 minutes and honours one for an + # hour, so it is signed once and reused rather than per push. + @refresh_after_seconds 2_400 + + def client() do + middleware = [ + {Tesla.Middleware.BaseUrl, config()[:base_url]}, + Tesla.Middleware.JSON + ] + + Tesla.client(middleware) + end + + @doc """ + Sends one payload to one device. Returns `{:error, :not_configured}` where no signing key is + set, so a machine without a `.p8` runs everything else unchanged. + """ + def push(device_token, payload, headers) when is_binary(device_token) do + case config()[:private_key] do + key when is_binary(key) -> + Tesla.post(client(), "/3/device/#{device_token}", payload, + headers: [ + {"authorization", "bearer " <> provider_token(key)}, + {"apns-topic", config()[:topic]} | headers + ] + ) + + nil -> + {:error, :not_configured} + end + end + + defp provider_token(key) do + now = System.system_time(:second) + + case :persistent_term.get({__MODULE__, :provider_token}, nil) do + {token, issued_at} when now - issued_at < @refresh_after_seconds -> token + _stale -> sign(key, now) + end + end + + defp sign(key, now) do + signer = Joken.Signer.create("ES256", %{"pem" => key}, %{"kid" => config()[:key_id]}) + + {:ok, token, _claims} = + Joken.encode_and_sign(%{"iss" => config()[:team_id], "iat" => now}, signer) + + :persistent_term.put({__MODULE__, :provider_token}, {token, now}) + + token + end + + defp config(), do: Application.get_env(:spendable, __MODULE__) +end diff --git a/lib/spendable/accounts/clients/apns_test.exs b/lib/spendable/accounts/clients/apns_test.exs new file mode 100644 index 00000000..974b1bf8 --- /dev/null +++ b/lib/spendable/accounts/clients/apns_test.exs @@ -0,0 +1,66 @@ +defmodule Spendable.Accounts.Clients.ApnsTest do + # Not async: the unconfigured case is the absence of application config, which is global. + use Spendable.DataCase, async: false + + alias Spendable.Accounts.Clients.Apns + + @device_token String.duplicate("ab", 32) + @payload %{aps: %{"content-available": 1}} + + setup do + configured = Application.get_env(:spendable, Apns) + + on_exit(fn -> Application.put_env(:spendable, Apns, configured) end) + + %{configured: configured} + end + + test "signs the request with a provider token for the topic" do + expect(TeslaMock, :call, fn %{method: :post, url: url, headers: headers}, _opts -> + send(self(), {:pushed, url, headers}) + + TeslaHelper.response(status: 200) + end) + + assert {:ok, %Tesla.Env{status: 200}} = Apns.push(@device_token, @payload, []) + + assert_received {:pushed, url, headers} + assert url == "https://api.push.apple.com/3/device/#{@device_token}" + assert {"apns-topic", "fiftysevenmedia.Spendable"} in headers + assert {_authorization, "bearer " <> _jwt} = List.keyfind(headers, "authorization", 0) + end + + test "passes the caller's headers through" do + expect(TeslaMock, :call, fn %{headers: headers}, _opts -> + send(self(), {:pushed, headers}) + + TeslaHelper.response(status: 200) + end) + + {:ok, _env} = Apns.push(@device_token, @payload, [{"apns-push-type", "background"}]) + + assert_received {:pushed, headers} + assert {"apns-push-type", "background"} in headers + end + + # APNs rejects a provider token minted more than once every twenty minutes. + test "reuses one provider token across pushes" do + stub(TeslaMock, :call, fn %{headers: headers}, _opts -> + send(self(), {:pushed, List.keyfind(headers, "authorization", 0)}) + + TeslaHelper.response(status: 200) + end) + + {:ok, _first} = Apns.push(@device_token, @payload, []) + {:ok, _second} = Apns.push(@device_token, @payload, []) + + assert_received {:pushed, authorization} + assert_received {:pushed, ^authorization} + end + + test "refuses to send when no signing key is configured", %{configured: configured} do + Application.put_env(:spendable, Apns, Keyword.delete(configured, :private_key)) + + assert {:error, :not_configured} = Apns.push(@device_token, @payload, []) + end +end diff --git a/lib/spendable/accounts/jobs/send_notification.ex b/lib/spendable/accounts/jobs/send_notification.ex new file mode 100644 index 00000000..5ee43bfa --- /dev/null +++ b/lib/spendable/accounts/jobs/send_notification.ex @@ -0,0 +1,18 @@ +defmodule Spendable.Accounts.Jobs.SendNotification do + @moduledoc false + + use Oban.Worker, queue: :notifications, max_attempts: 5 + + alias Spendable.Accounts + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + %{"user_id" => user_id, "count" => count, "total" => total, "alert" => alert} = args + + Accounts.deliver_notification(user_id, %{ + count: count, + total: Decimal.new(total), + alert: alert + }) + end +end diff --git a/lib/spendable/accounts/jobs/send_notification_test.exs b/lib/spendable/accounts/jobs/send_notification_test.exs new file mode 100644 index 00000000..ea919fdf --- /dev/null +++ b/lib/spendable/accounts/jobs/send_notification_test.exs @@ -0,0 +1,36 @@ +defmodule Spendable.Accounts.Jobs.SendNotificationTest do + use Spendable.DataCase, async: true + + alias Spendable.Accounts + alias Spendable.Accounts.Jobs.SendNotification + alias Spendable.Scope + + @device_token String.duplicate("ab", 32) + + setup do + {:ok, user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + scope = Scope.for_user(user) + + {:ok, api_token} = Accounts.create_api_token(scope, %{}) + {:ok, _registered} = Accounts.register_apns_token(scope, api_token, @device_token) + + %{user: user} + end + + test "pushes what the job carries", %{user: user} do + expect(TeslaMock, :call, fn %{body: body}, _opts -> + send(self(), {:pushed, body}) + + TeslaHelper.response(status: 200) + end) + + args = %{"user_id" => user.id, "count" => 2, "total" => "-40.00", "alert" => true} + + assert :ok = perform_job(SendNotification, args) + + assert_received {:pushed, body} + assert %{"aps" => %{"alert" => %{"body" => "2 new transactions · $40.00"}}} = Jason.decode!(body) + end +end diff --git a/lib/spendable/accounts/schemas/api_token.ex b/lib/spendable/accounts/schemas/api_token.ex index c2483126..036119f4 100644 --- a/lib/spendable/accounts/schemas/api_token.ex +++ b/lib/spendable/accounts/schemas/api_token.ex @@ -26,6 +26,10 @@ defmodule Spendable.Accounts.Schemas.ApiToken do |> cast(attrs, [:device_name, :apns_token]) |> validate_required([:token_hash, :last_used_at, :expires_at]) |> validate_length(:device_name, max: 100) + # Apple's device token is hex. Its length is 32 bytes today but has grown before, so only the + # floor is pinned - rejecting a longer one would silently stop every device registering. + |> validate_format(:apns_token, ~r/\A[0-9a-f]+\z/i) + |> validate_length(:apns_token, min: 64, max: 256) |> unique_constraint(:token_hash) end diff --git a/lib/spendable/application.ex b/lib/spendable/application.ex index fb5e4fd2..086e97f9 100644 --- a/lib/spendable/application.ex +++ b/lib/spendable/application.ex @@ -8,7 +8,14 @@ defmodule Spendable.Application do @impl true def start(_type, _args) do children = [ - {Finch, name: Spendable.Finch}, + # APNs speaks HTTP/2 only, and Finch's default pool is HTTP/1. Both hosts are named because + # a build signed for development registers against the sandbox one. + {Finch, + name: Spendable.Finch, + pools: %{ + "https://api.push.apple.com" => [protocols: [:http2], count: 1], + "https://api.sandbox.push.apple.com" => [protocols: [:http2], count: 1] + }}, SpendableWeb.Telemetry, Spendable.Repo, {Oban, Application.fetch_env!(:spendable, Oban)}, diff --git a/lib/spendable/banks/actions/create_bank_member_from_public_token.ex b/lib/spendable/banks/actions/create_bank_member_from_public_token.ex index c9a2905a..4b21766c 100644 --- a/lib/spendable/banks/actions/create_bank_member_from_public_token.ex +++ b/lib/spendable/banks/actions/create_bank_member_from_public_token.ex @@ -34,7 +34,8 @@ defmodule Spendable.Banks.Actions.CreateBankMemberFromPublicToken do |> Repo.insert() |> case do {:ok, bank_member} -> - {:ok, _job} = Banks.queue_sync(bank_member) + # Silently: a month of history landing at once is not news the user wants pushed at them. + {:ok, _job} = Banks.queue_sync(bank_member, notify: false) {:ok, bank_member} {:error, changeset} -> diff --git a/lib/spendable/banks/actions/create_bank_member_from_public_token_test.exs b/lib/spendable/banks/actions/create_bank_member_from_public_token_test.exs index ffc6164d..5246f017 100644 --- a/lib/spendable/banks/actions/create_bank_member_from_public_token_test.exs +++ b/lib/spendable/banks/actions/create_bank_member_from_public_token_test.exs @@ -47,6 +47,14 @@ defmodule Spendable.Banks.Actions.CreateBankMemberFromPublicTokenTest do assert_enqueued(worker: SyncMember, args: %{bank_member_id: bank_member.id}) end + # A month of history arriving at once is not news the user wants pushed at them. + test "queues it silently", %{scope: scope} do + {:ok, bank_member} = + Banks.create_bank_member_from_public_token(scope, "public-sandbox-token") + + assert_enqueued(worker: SyncMember, args: %{bank_member_id: bank_member.id, notify: false}) + end + # Reconnecting a bank the user already holds would be a second copy of the same connection. test "errors when the same connection is added twice" do {:ok, user} = diff --git a/lib/spendable/banks/actions/queue_historical_sync.ex b/lib/spendable/banks/actions/queue_historical_sync.ex index a5f39431..3965c03b 100644 --- a/lib/spendable/banks/actions/queue_historical_sync.ex +++ b/lib/spendable/banks/actions/queue_historical_sync.ex @@ -7,12 +7,20 @@ defmodule Spendable.Banks.Actions.QueueHistoricalSync do @months 24 - @doc "A sync the user asks for by hand, reaching back as far as Plaid serves history." + @doc """ + A sync the user asks for by hand, reaching back as far as Plaid serves history. + + It notifies silently: two years of history would otherwise arrive as an alert counting charges + the user has seen for months. + """ def queue_historical_sync( %Scope{user: %{id: user_id}}, %BankMember{user_id: user_id} = bank_member ) do - Banks.queue_sync(bank_member, start_date: Date.shift(Date.utc_today(), month: -@months)) + Banks.queue_sync(bank_member, + notify: false, + start_date: Date.shift(Date.utc_today(), month: -@months) + ) end def queue_historical_sync(%Scope{}, %BankMember{}), do: {:error, :not_authorized} diff --git a/lib/spendable/banks/actions/queue_historical_sync_test.exs b/lib/spendable/banks/actions/queue_historical_sync_test.exs index 488c06dc..a7039236 100644 --- a/lib/spendable/banks/actions/queue_historical_sync_test.exs +++ b/lib/spendable/banks/actions/queue_historical_sync_test.exs @@ -33,6 +33,13 @@ defmodule Spendable.Banks.Actions.QueueHistoricalSyncTest do ) end + # Two years of history is not news, so the app is told and the user is not. + test "queues it silently", %{scope: scope, bank_member: bank_member} do + {:ok, _job} = Banks.queue_historical_sync(scope, bank_member) + + assert_enqueued(worker: Spendable.Banks.Jobs.SyncMember, args: %{notify: false}) + end + test "refuses another user's connection", %{bank_member: bank_member} do {:ok, other} = Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) diff --git a/lib/spendable/banks/actions/queue_sync.ex b/lib/spendable/banks/actions/queue_sync.ex index ed7467a7..eaf5511a 100644 --- a/lib/spendable/banks/actions/queue_sync.ex +++ b/lib/spendable/banks/actions/queue_sync.ex @@ -6,15 +6,20 @@ defmodule Spendable.Banks.Actions.QueueSync do @doc """ Syncing talks to Plaid for as long as it takes, so it never runs in the request. - Pass `:start_date` to reach further back than the sync's default window. + Pass `:start_date` to reach further back than the sync's default window, and `notify: false` to + hold back the alert on a run whose size would make it noise. """ def queue_sync(%BankMember{} = bank_member, opts \\ []) do %{bank_member_id: bank_member.id} |> put_start_date(opts[:start_date]) + |> put_notify(opts[:notify]) |> SyncMember.new() |> Oban.insert() end defp put_start_date(args, %Date{} = start_date), do: Map.put(args, :start_date, start_date) defp put_start_date(args, _start_date), do: args + + defp put_notify(args, false), do: Map.put(args, :notify, false) + defp put_notify(args, _notify), do: args end diff --git a/lib/spendable/banks/actions/sync_member.ex b/lib/spendable/banks/actions/sync_member.ex index 12adfeb8..2f6bfa08 100644 --- a/lib/spendable/banks/actions/sync_member.ex +++ b/lib/spendable/banks/actions/sync_member.ex @@ -4,6 +4,7 @@ defmodule Spendable.Banks.Actions.SyncMember do import Ecto.Query import Spendable.Banks.Utils.FormatBankMember + alias Spendable.Accounts alias Spendable.Banks.Clients.Plaid alias Spendable.Banks.Schemas.BankAccount alias Spendable.Banks.Schemas.BankMember @@ -22,6 +23,10 @@ defmodule Spendable.Banks.Actions.SyncMember do Takes an id rather than a record because the only caller is the job queue, which carries ids. Runs under a system scope: the work is ours, but the rows are still the member's owner's. Activity is pulled from `:start_date`, defaulting to the last #{@default_days} days. + + Notifies the user once at the end, never per charge. `notify: false` keeps the alert back on a + run that is expected to be large - the first sync of a connection, or a backfill the user is + already watching - while still telling the app the run finished. """ def sync_member(bank_member_id, opts \\ []) when is_binary(bank_member_id) do case Repo.get(BankMember, bank_member_id) do @@ -34,15 +39,27 @@ defmodule Spendable.Banks.Actions.SyncMember do |> sync_item(scope) |> sync_accounts(scope) |> Enum.filter(& &1.sync) - |> Enum.each(&sync_transactions(&1, scope, bank_member, start_date)) - - :ok + |> Enum.flat_map(&sync_transactions(&1, scope, bank_member, start_date)) + |> notify(scope, opts) nil -> {:error, :bank_member_not_found} end end + defp notify(bank_transactions, scope, opts) do + total = Enum.reduce(bank_transactions, Decimal.new(0), &Decimal.add(&2, &1.amount)) + + {:ok, _job} = + Accounts.notify_user(scope, %{ + count: length(bank_transactions), + total: total, + alert: Keyword.get(opts, :notify, true) + }) + + :ok + end + defp sync_item(bank_member, _scope) do {:ok, %{body: item}} = Plaid.item(bank_member.plaid_token) @@ -83,19 +100,25 @@ defmodule Spendable.Banks.Actions.SyncMember do case Plaid.account_transactions(bank_member.plaid_token, account.external_id, start_date, opts) do {:ok, %{body: %{"transactions" => transactions} = response}} -> - Enum.each(transactions, &sync_transaction(&1, account, scope)) + synced = Enum.flat_map(transactions, &sync_transaction(&1, account, scope)) + + rest = + with %{"total_transactions" => total} when total > offset + @page_size <- response do + sync_transactions(account, scope, bank_member, start_date, offset + @page_size) + else + _last_page -> [] + end - with %{"total_transactions" => total} when total > offset + @page_size <- response do - sync_transactions(account, scope, bank_member, start_date, offset + @page_size) - end + synced ++ rest {:ok, %{body: %{"error_code" => "PRODUCT_NOT_READY"}}} -> - :ok + [] end end # Plaid replays transactions we already hold, so a duplicate external id is the normal case and - # not an error worth failing the sync over. + # not an error worth failing the sync over. Returns the inserts, which are what the user is told + # about. defp sync_transaction(details, account, scope) do attrs = format_bank_transaction(details) @@ -103,8 +126,13 @@ defmodule Spendable.Banks.Actions.SyncMember do |> BankTransaction.changeset(attrs) |> Repo.insert() |> case do - {:ok, bank_transaction} -> create_transaction(bank_transaction, details, scope) - {:error, _already_synced} -> :ok + {:ok, bank_transaction} -> + create_transaction(bank_transaction, details, scope) + + [bank_transaction] + + {:error, _already_synced} -> + [] end end diff --git a/lib/spendable/banks/actions/sync_member_test.exs b/lib/spendable/banks/actions/sync_member_test.exs index 607a902c..35a16797 100644 --- a/lib/spendable/banks/actions/sync_member_test.exs +++ b/lib/spendable/banks/actions/sync_member_test.exs @@ -2,6 +2,7 @@ defmodule Spendable.Banks.Actions.SyncMemberTest do use Spendable.DataCase, async: true alias Spendable.Accounts + alias Spendable.Accounts.Jobs.SendNotification alias Spendable.Banks alias Spendable.Banks.Schemas.BankMember alias Spendable.Scope @@ -210,6 +211,74 @@ defmodule Spendable.Banks.Actions.SyncMemberTest do assert [] = Transactions.list_transactions(scope) end + # Fifty charges are one thing that happened, and the user hears about it once. + test "notifies once for the run, not once per charge", %{bank_member: bank_member} do + :ok = Banks.sync_member(bank_member.id) + + assert [%Oban.Job{args: %{"count" => count, "alert" => true}}] = + all_enqueued(worker: SendNotification) + + assert count > 1 + end + + # Plaid replays charges we already hold, and a replay is not something to be told about. + test "counts only what the run was the first to see", %{bank_member: bank_member} do + page = TestData.Plaid.account_transactions("zyBMmKBpeZcDVZgqEx3ACKveJjvwmBHomPbyP") + [transaction | _rest] = page["transactions"] + + stub(TeslaMock, :call, fn + %{method: :post, url: "https://sandbox.plaid.com/item/get"}, _opts -> + TeslaHelper.response(body: TestData.Plaid.item()) + + %{method: :post, url: "https://sandbox.plaid.com/institutions/get_by_id"}, _opts -> + TeslaHelper.response(body: TestData.Plaid.institution()) + + %{method: :post, url: "https://sandbox.plaid.com/accounts/get"}, _opts -> + TeslaHelper.response(body: TestData.Plaid.accounts()) + + %{method: :post, url: "https://sandbox.plaid.com/transactions/get"}, _opts -> + TeslaHelper.response(body: %{page | "transactions" => [transaction], "total_transactions" => 1}) + end) + + :ok = Banks.sync_member(bank_member.id) + :ok = Banks.sync_member(bank_member.id) + + assert [%Oban.Job{args: %{"count" => 0}}, %Oban.Job{args: %{"count" => 1}}] = + all_enqueued(worker: SendNotification) + end + + # The silent half is the only signal the app gets that a sync it asked for has finished. + test "notifies when the run found nothing", %{bank_member: bank_member} do + page = TestData.Plaid.account_transactions("zyBMmKBpeZcDVZgqEx3ACKveJjvwmBHomPbyP") + + stub(TeslaMock, :call, fn + %{method: :post, url: "https://sandbox.plaid.com/item/get"}, _opts -> + TeslaHelper.response(body: TestData.Plaid.item()) + + %{method: :post, url: "https://sandbox.plaid.com/institutions/get_by_id"}, _opts -> + TeslaHelper.response(body: TestData.Plaid.institution()) + + %{method: :post, url: "https://sandbox.plaid.com/accounts/get"}, _opts -> + TeslaHelper.response(body: TestData.Plaid.accounts()) + + %{method: :post, url: "https://sandbox.plaid.com/transactions/get"}, _opts -> + TeslaHelper.response(body: %{page | "transactions" => [], "total_transactions" => 0}) + end) + + :ok = Banks.sync_member(bank_member.id) + + assert [%Oban.Job{args: %{"count" => 0, "alert" => true}}] = + all_enqueued(worker: SendNotification) + end + + test "holds the alert back on a run that was not the user's to hear about", %{ + bank_member: bank_member + } do + :ok = Banks.sync_member(bank_member.id, notify: false) + + assert [%Oban.Job{args: %{"alert" => false}}] = all_enqueued(worker: SendNotification) + end + test "errors when no member has that id" do assert {:error, :bank_member_not_found} = Banks.sync_member("bkm_01M036GTQ48JXS0A2AXFNV6H5P") diff --git a/lib/spendable/banks/jobs/sync_member.ex b/lib/spendable/banks/jobs/sync_member.ex index 40bdb886..02434694 100644 --- a/lib/spendable/banks/jobs/sync_member.ex +++ b/lib/spendable/banks/jobs/sync_member.ex @@ -10,6 +10,10 @@ defmodule Spendable.Banks.Jobs.SyncMember do Banks.sync_member(bank_member_id, sync_opts(args)) end - defp sync_opts(%{"start_date" => start_date}), do: [start_date: Date.from_iso8601!(start_date)] - defp sync_opts(_args), do: [] + defp sync_opts(%{"start_date" => start_date} = args), + do: [notify: notify?(args), start_date: Date.from_iso8601!(start_date)] + + defp sync_opts(args), do: [notify: notify?(args)] + + defp notify?(args), do: Map.get(args, "notify", true) end diff --git a/lib/spendable/banks/jobs/sync_member_test.exs b/lib/spendable/banks/jobs/sync_member_test.exs index 80a81f49..002c9fad 100644 --- a/lib/spendable/banks/jobs/sync_member_test.exs +++ b/lib/spendable/banks/jobs/sync_member_test.exs @@ -54,4 +54,13 @@ defmodule Spendable.Banks.Jobs.SyncMemberTest do assert_received {:transactions_request, %{"start_date" => "2024-01-01"}} end + + test "carries the alert the job was queued with", %{bank_member: bank_member} do + args = %{"bank_member_id" => bank_member.id, "start_date" => "2024-01-01", "notify" => false} + + assert :ok = perform_job(SyncMember, args) + + assert [%Oban.Job{args: %{"alert" => false}}] = + all_enqueued(worker: Spendable.Accounts.Jobs.SendNotification) + end end diff --git a/lib/spendable_web/api/controllers/session_controller.ex b/lib/spendable_web/api/controllers/session_controller.ex index 1e1fb88a..320ec65f 100644 --- a/lib/spendable_web/api/controllers/session_controller.ex +++ b/lib/spendable_web/api/controllers/session_controller.ex @@ -6,6 +6,7 @@ defmodule SpendableWeb.Api.SessionController do alias SpendableWeb.Api.Schemas.Errors alias SpendableWeb.Api.Schemas.Session alias SpendableWeb.Api.Schemas.SessionRequest + alias SpendableWeb.Api.Schemas.SessionUpdateRequest tags(["session"]) @@ -32,6 +33,30 @@ defmodule SpendableWeb.Api.SessionController do end end + operation(:update, + operation_id: "updateSession", + summary: "Register this device for push", + description: """ + Sends the APNs device token iOS issued the app. It is held against the token the request was + made with, so signing out stops the pushes with it. + """, + request_body: {"Device token", "application/json", SessionUpdateRequest}, + responses: [ + no_content: {"Registered", "application/json", nil}, + unauthorized: {"Errors", "application/json", Errors}, + unprocessable_entity: {"Errors", "application/json", Errors} + ] + ) + + def update(conn, %{"apns_token" => apns_token}) do + scope = conn.assigns.current_scope + + with {:ok, _api_token} <- + Accounts.register_apns_token(scope, conn.assigns.current_api_token, apns_token) do + send_resp(conn, :no_content, "") + end + end + operation(:delete, operation_id: "deleteSession", summary: "Sign out", diff --git a/lib/spendable_web/api/controllers/session_controller_test.exs b/lib/spendable_web/api/controllers/session_controller_test.exs index 9327f963..8aa4f34d 100644 --- a/lib/spendable_web/api/controllers/session_controller_test.exs +++ b/lib/spendable_web/api/controllers/session_controller_test.exs @@ -64,6 +64,34 @@ defmodule SpendableWeb.Api.SessionControllerTest do refute apple_user_id == user_id end + test "registers the device for push", %{conn: conn} do + {:ok, user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + {:ok, api_token} = Accounts.create_api_token(Scope.for_user(user), %{}) + apns_token = String.duplicate("ab", 32) + + conn = put_req_header(conn, "authorization", "Bearer " <> api_token.token) + + assert conn |> patch(~p"/api/session", %{"apns_token" => apns_token}) |> response(204) + assert {:ok, %{apns_token: ^apns_token}} = Accounts.authenticate_api_token(api_token.token) + end + + test "rejects a device token that is not one", %{conn: conn} do + {:ok, user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + {:ok, api_token} = Accounts.create_api_token(Scope.for_user(user), %{}) + + response = + conn + |> put_req_header("authorization", "Bearer " <> api_token.token) + |> patch(~p"/api/session", %{"apns_token" => "nope"}) + |> json_response(422) + + assert_schema(response, "Errors", @api_spec) + end + test "rejects an ID token Google did not sign", %{conn: conn} do body = %{"provider" => "google", "id_token" => TestData.Google.id_token_from_unknown_key()} diff --git a/lib/spendable_web/api/schemas/session_update_request.ex b/lib/spendable_web/api/schemas/session_update_request.ex new file mode 100644 index 00000000..c30336d7 --- /dev/null +++ b/lib/spendable_web/api/schemas/session_update_request.ex @@ -0,0 +1,22 @@ +defmodule SpendableWeb.Api.Schemas.SessionUpdateRequest do + @moduledoc false + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SessionUpdateRequest", + description: "The device this token was issued to, so the server can push to it.", + type: :object, + properties: %{ + apns_token: %Schema{ + type: :string, + pattern: ~r/\A[0-9a-f]+\z/i, + minLength: 64, + maxLength: 256, + description: "The hex device token iOS handed the app." + } + }, + required: [:apns_token] + }) +end diff --git a/lib/spendable_web/router.ex b/lib/spendable_web/router.ex index 13deb9d7..950dfad2 100644 --- a/lib/spendable_web/router.ex +++ b/lib/spendable_web/router.ex @@ -98,6 +98,7 @@ defmodule SpendableWeb.Router do scope "/api", SpendableWeb.Api do pipe_through [:api, :api_authenticated] + patch "/session", SessionController, :update delete "/session", SessionController, :delete get "/me", MeController, :show diff --git a/mix.exs b/mix.exs index b9c5b084..739c1343 100644 --- a/mix.exs +++ b/mix.exs @@ -101,7 +101,7 @@ defmodule Spendable.MixProject do # The spec is read off the router and the controller modules, so booting the app - and with # it a database - buys nothing. openapi: [ - "openapi.spec.json --spec SpendableWeb.Api.ApiSpec --pretty --no-start-app priv/static/openapi.json" + "openapi.spec.json --spec SpendableWeb.Api.ApiSpec --pretty --no-start-app --filename priv/static/openapi.json" ] ] end diff --git a/mobile/api/README.md b/mobile/api/README.md index 39d97d8e..cd6accd6 100644 --- a/mobile/api/README.md +++ b/mobile/api/README.md @@ -83,6 +83,7 @@ Class | Method | HTTP request | Description [*SessionApi*](doc/SessionApi.md) | [**deleteIdentity**](doc/SessionApi.md#deleteidentity) | **DELETE** /api/identities/{id} | Remove a way to sign in [*SessionApi*](doc/SessionApi.md) | [**deleteSession**](doc/SessionApi.md#deletesession) | **DELETE** /api/session | Sign out [*SessionApi*](doc/SessionApi.md) | [**getCurrentUser**](doc/SessionApi.md#getcurrentuser) | **GET** /api/me | The signed-in user +[*SessionApi*](doc/SessionApi.md) | [**updateSession**](doc/SessionApi.md#updatesession) | **PATCH** /api/session | Register this device for push [*SplitsApi*](doc/SplitsApi.md) | [**archiveSplit**](doc/SplitsApi.md#archivesplit) | **DELETE** /api/splits/{id} | Archive a split [*SplitsApi*](doc/SplitsApi.md) | [**createSplit**](doc/SplitsApi.md#createsplit) | **POST** /api/splits | Create a split [*SplitsApi*](doc/SplitsApi.md) | [**getSplit**](doc/SplitsApi.md#getsplit) | **GET** /api/splits/{id} | Get a split and its lines @@ -121,6 +122,7 @@ Class | Method | HTTP request | Description - [MonthSpend](doc/MonthSpend.md) - [Session](doc/Session.md) - [SessionRequest](doc/SessionRequest.md) + - [SessionUpdateRequest](doc/SessionUpdateRequest.md) - [Split](doc/Split.md) - [SplitLine](doc/SplitLine.md) - [SplitLineRequest](doc/SplitLineRequest.md) diff --git a/mobile/api/lib/spendable_api.dart b/mobile/api/lib/spendable_api.dart index d0e2d9f4..465008c7 100644 --- a/mobile/api/lib/spendable_api.dart +++ b/mobile/api/lib/spendable_api.dart @@ -36,6 +36,7 @@ export 'package:spendable_api/src/model/link_token.dart'; export 'package:spendable_api/src/model/month_spend.dart'; export 'package:spendable_api/src/model/session.dart'; export 'package:spendable_api/src/model/session_request.dart'; +export 'package:spendable_api/src/model/session_update_request.dart'; export 'package:spendable_api/src/model/split.dart'; export 'package:spendable_api/src/model/split_line.dart'; export 'package:spendable_api/src/model/split_line_request.dart'; diff --git a/mobile/api/lib/src/api/session_api.dart b/mobile/api/lib/src/api/session_api.dart index ce32812c..15fb2c9c 100644 --- a/mobile/api/lib/src/api/session_api.dart +++ b/mobile/api/lib/src/api/session_api.dart @@ -13,6 +13,7 @@ import 'package:spendable_api/src/model/errors.dart'; import 'package:spendable_api/src/model/identity.dart'; import 'package:spendable_api/src/model/session.dart'; import 'package:spendable_api/src/model/session_request.dart'; +import 'package:spendable_api/src/model/session_update_request.dart'; import 'package:spendable_api/src/model/user.dart'; class SessionApi { @@ -402,4 +403,77 @@ class SessionApi { ); } + /// Register this device for push + /// Sends the APNs device token iOS issued the app. It is held against the token the request was made with, so signing out stops the pushes with it. + /// + /// Parameters: + /// * [sessionUpdateRequest] - Device token + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] + /// Throws [DioException] if API call or serialization fails + Future> updateSession({ + SessionUpdateRequest? sessionUpdateRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/api/session'; + final _options = Options( + method: r'PATCH', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(SessionUpdateRequest); + _bodyData = sessionUpdateRequest == null ? null : _serializers.serialize(sessionUpdateRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + return _response; + } + } diff --git a/mobile/api/lib/src/model/session_update_request.dart b/mobile/api/lib/src/model/session_update_request.dart new file mode 100644 index 00000000..2ba94364 --- /dev/null +++ b/mobile/api/lib/src/model/session_update_request.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'session_update_request.g.dart'; + +/// The device this token was issued to, so the server can push to it. +/// +/// Properties: +/// * [apnsToken] - The hex device token iOS handed the app. +@BuiltValue() +abstract class SessionUpdateRequest implements Built { + /// The hex device token iOS handed the app. + @BuiltValueField(wireName: r'apns_token') + String get apnsToken; + + SessionUpdateRequest._(); + + factory SessionUpdateRequest([void updates(SessionUpdateRequestBuilder b)]) = _$SessionUpdateRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(SessionUpdateRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$SessionUpdateRequestSerializer(); +} + +class _$SessionUpdateRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [SessionUpdateRequest, _$SessionUpdateRequest]; + + @override + final String wireName = r'SessionUpdateRequest'; + + Iterable _serializeProperties( + Serializers serializers, + SessionUpdateRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'apns_token'; + yield serializers.serialize( + object.apnsToken, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + SessionUpdateRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required SessionUpdateRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'apns_token': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.apnsToken = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + SessionUpdateRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = SessionUpdateRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/mobile/api/lib/src/model/session_update_request.g.dart b/mobile/api/lib/src/model/session_update_request.g.dart new file mode 100644 index 00000000..cfa7830b --- /dev/null +++ b/mobile/api/lib/src/model/session_update_request.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'session_update_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$SessionUpdateRequest extends SessionUpdateRequest { + @override + final String apnsToken; + + factory _$SessionUpdateRequest( + [void Function(SessionUpdateRequestBuilder)? updates]) => + (SessionUpdateRequestBuilder()..update(updates))._build(); + + _$SessionUpdateRequest._({required this.apnsToken}) : super._(); + @override + SessionUpdateRequest rebuild( + void Function(SessionUpdateRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + SessionUpdateRequestBuilder toBuilder() => + SessionUpdateRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is SessionUpdateRequest && apnsToken == other.apnsToken; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, apnsToken.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'SessionUpdateRequest') + ..add('apnsToken', apnsToken)) + .toString(); + } +} + +class SessionUpdateRequestBuilder + implements Builder { + _$SessionUpdateRequest? _$v; + + String? _apnsToken; + String? get apnsToken => _$this._apnsToken; + set apnsToken(String? apnsToken) => _$this._apnsToken = apnsToken; + + SessionUpdateRequestBuilder() { + SessionUpdateRequest._defaults(this); + } + + SessionUpdateRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _apnsToken = $v.apnsToken; + _$v = null; + } + return this; + } + + @override + void replace(SessionUpdateRequest other) { + _$v = other as _$SessionUpdateRequest; + } + + @override + void update(void Function(SessionUpdateRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + SessionUpdateRequest build() => _build(); + + _$SessionUpdateRequest _build() { + final _$result = _$v ?? + _$SessionUpdateRequest._( + apnsToken: BuiltValueNullFieldError.checkNotNull( + apnsToken, r'SessionUpdateRequest', 'apnsToken'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/mobile/api/lib/src/serializers.dart b/mobile/api/lib/src/serializers.dart index a7b727e6..a5aa55df 100644 --- a/mobile/api/lib/src/serializers.dart +++ b/mobile/api/lib/src/serializers.dart @@ -34,6 +34,7 @@ import 'package:spendable_api/src/model/link_token.dart'; import 'package:spendable_api/src/model/month_spend.dart'; import 'package:spendable_api/src/model/session.dart'; import 'package:spendable_api/src/model/session_request.dart'; +import 'package:spendable_api/src/model/session_update_request.dart'; import 'package:spendable_api/src/model/split.dart'; import 'package:spendable_api/src/model/split_line.dart'; import 'package:spendable_api/src/model/split_line_request.dart'; @@ -67,6 +68,7 @@ part 'serializers.g.dart'; MonthSpend, Session, SessionRequest, + SessionUpdateRequest, Split, SplitLine, SplitLineRequest, diff --git a/mobile/api/lib/src/serializers.g.dart b/mobile/api/lib/src/serializers.g.dart index 604ed01d..578ec9f0 100644 --- a/mobile/api/lib/src/serializers.g.dart +++ b/mobile/api/lib/src/serializers.g.dart @@ -31,6 +31,7 @@ Serializers _$serializers = (Serializers().toBuilder() ..add(Session.serializer) ..add(SessionRequest.serializer) ..add(SessionRequestProviderEnum.serializer) + ..add(SessionUpdateRequest.serializer) ..add(Split.serializer) ..add(SplitLine.serializer) ..add(SplitLineRequest.serializer) diff --git a/openapi.json b/openapi.json deleted file mode 100644 index b652d99d..00000000 --- a/openapi.json +++ /dev/null @@ -1,2797 +0,0 @@ -{ - "components": { - "responses": {}, - "schemas": { - "BankAccount": { - "description": "An account inside a connection. Amounts are decimal strings.", - "properties": { - "balance": { - "description": "Negative on a credit card, as a ledger reads.", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budget_id": { - "description": "When set, this account's balance is that budget's balance.", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "number": { - "description": "Masked, last digits only.", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "sub_type": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "sync": { - "description": "Whether activity is pulled and counted.", - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "type": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "name", - "type", - "sub_type", - "balance", - "sync" - ], - "title": "BankAccount", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BankAccount", - "x-validate": null - }, - "BankAccountRequest": { - "description": "The two things a user decides about an account: whether to sync it, and where it belongs.", - "example": { - "budget_id": "bgt_01j0rent", - "sync": true - }, - "properties": { - "budget_id": { - "description": "Null unassigns, putting the balance back into Spendable.", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "sync": { - "type": "boolean", - "x-struct": null, - "x-validate": null - } - }, - "title": "BankAccountRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BankAccountRequest", - "x-validate": null - }, - "BankMember": { - "description": "A connection to an institution, and the accounts inside it.", - "properties": { - "bank_accounts": { - "items": { - "$ref": "#/components/schemas/BankAccount" - }, - "type": "array", - "x-struct": null, - "x-validate": null - }, - "has_logo": { - "description": "Fetch it from `/api/banks/{id}/logo` when true.", - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "provider": { - "description": "Who supplies the connection.", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "status": { - "description": "Anything other than \"CONNECTED\" means the user has to reconnect.", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "name", - "provider", - "has_logo", - "bank_accounts" - ], - "title": "BankMember", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BankMember", - "x-validate": null - }, - "Budget": { - "description": "An envelope money is divided into. Amounts are decimal strings.", - "properties": { - "archived_at": { - "format": "date-time", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "balance": { - "description": "What the allocations add up to, or the bank account's balance when assigned.", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budgeted_amount": { - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "type": { - "enum": [ - "tracking", - "envelope", - "goal" - ], - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "name", - "type", - "balance" - ], - "title": "Budget", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.Budget", - "x-validate": null - }, - "BudgetAllocation": { - "description": "One budget's share of a transaction.", - "properties": { - "amount": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budget_id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "amount", - "budget_id" - ], - "title": "BudgetAllocation", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BudgetAllocation", - "x-validate": null - }, - "BudgetAllocationRequest": { - "description": "An allocation to keep, update or add. Distinct from BudgetAllocation because `id` is optional.\n", - "properties": { - "amount": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budget_id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "description": "Omit to add a new allocation.", - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "amount", - "budget_id" - ], - "title": "BudgetAllocationRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BudgetAllocationRequest", - "x-validate": null - }, - "BudgetRequest": { - "description": "Amounts are decimal strings. `balance` is what the user wants the budget to hold - the server\nworks out the adjustment that gets it there, so never send `adjustment`.\n", - "example": { - "budgeted_amount": "400.00", - "name": "Groceries", - "type": "envelope" - }, - "properties": { - "balance": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budgeted_amount": { - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "type": { - "enum": [ - "tracking", - "envelope", - "goal" - ], - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "title": "BudgetRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BudgetRequest", - "x-validate": null - }, - "BudgetSummary": { - "description": "Everything the budgets screen shows for one month. Presentation is the client's - this is the\nnumbers behind it.\n", - "properties": { - "allocated_total": { - "description": "Budgeted across envelopes.", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budgets": { - "items": { - "$ref": "#/components/schemas/Budget" - }, - "type": "array", - "x-struct": null, - "x-validate": null - }, - "credit_card_balance": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "current_month": { - "description": "Spendable, allocated and credit cards only apply to the current month.", - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "month": { - "format": "date", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "spendable": { - "description": "Synced money no budget has claimed.", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "spent": { - "additionalProperties": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "description": "Spent this month, keyed by budget id. Every listed budget has an entry.", - "type": "object", - "x-struct": null, - "x-validate": null - }, - "spent_by_month": { - "description": "Newest first, for the month picker.", - "items": { - "$ref": "#/components/schemas/MonthSpend" - }, - "type": "array", - "x-struct": null, - "x-validate": null - }, - "spent_total": { - "description": "Spent across envelopes this month.", - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "month", - "current_month", - "spendable", - "allocated_total", - "spent_total", - "credit_card_balance", - "budgets", - "spent", - "spent_by_month" - ], - "title": "BudgetSummary", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BudgetSummary", - "x-validate": null - }, - "BulkFailure": { - "description": "One transaction a bulk change did not apply to, and why.", - "properties": { - "code": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "code" - ], - "title": "BulkFailure", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BulkFailure", - "x-validate": null - }, - "BulkRequest": { - "description": "Applies the same change to several transactions. `budget_id` spends each transaction's whole\namount from that budget, replacing whatever it was allocated to before.\n", - "example": { - "reviewed": true, - "transaction_ids": [ - "txn_01j0one", - "txn_01j0two" - ] - }, - "properties": { - "budget_id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "excluded": { - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "reviewed": { - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "transaction_ids": { - "items": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "minItems": 1, - "type": "array", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "transaction_ids" - ], - "title": "BulkRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BulkRequest", - "x-validate": null - }, - "BulkResult": { - "description": "A bulk change is applied per transaction rather than all or nothing, so the ones that worked\ncome back alongside the ones that did not.\n", - "properties": { - "failed": { - "items": { - "$ref": "#/components/schemas/BulkFailure" - }, - "type": "array", - "x-struct": null, - "x-validate": null - }, - "transactions": { - "items": { - "$ref": "#/components/schemas/Transaction" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "transactions", - "failed" - ], - "title": "BulkResult", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.BulkResult", - "x-validate": null - }, - "ConnectRequest": { - "description": "The public token Plaid Link returns once the user has picked a bank.", - "properties": { - "public_token": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "public_token" - ], - "title": "ConnectRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.ConnectRequest", - "x-validate": null - }, - "Errors": { - "description": "The shape every failed request comes back in.", - "properties": { - "errors": { - "items": { - "properties": { - "code": { - "description": "Machine-readable reason.", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "detail": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "source": { - "description": "Present on validation errors, pointing at the offending field.", - "properties": { - "pointer": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "type": "object", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "code", - "detail" - ], - "type": "object", - "x-struct": null, - "x-validate": null - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "errors" - ], - "title": "Errors", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.Errors", - "x-validate": null - }, - "Identity": { - "description": "A way of signing into an account.", - "properties": { - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "provider": { - "enum": [ - "apple", - "google" - ], - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "provider" - ], - "title": "Identity", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.Identity", - "x-validate": null - }, - "LinkToken": { - "description": "Hand this to the Plaid Link SDK. Short-lived.", - "properties": { - "link_token": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "link_token" - ], - "title": "LinkToken", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.LinkToken", - "x-validate": null - }, - "MonthSpend": { - "description": "A month the user has spent in, for the month picker.", - "properties": { - "month": { - "format": "date", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "spent": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "month", - "spent" - ], - "title": "MonthSpend", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.MonthSpend", - "x-validate": null - }, - "Session": { - "description": "An API token. The token itself is returned once and never again.", - "properties": { - "device_name": { - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "expires_at": { - "format": "date-time", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "token": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "token", - "expires_at" - ], - "title": "Session", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.Session", - "x-validate": null - }, - "SessionRequest": { - "description": "An ID token from a native sign-in, exchanged for an API token.", - "example": { - "device_name": "iPhone", - "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9.eyJzdWIiOiI0MiJ9.signature", - "provider": "google" - }, - "properties": { - "device_name": { - "description": "Shown when managing signed-in devices.", - "maxLength": 100, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id_token": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "provider": { - "enum": [ - "apple", - "google" - ], - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "provider", - "id_token" - ], - "title": "SessionRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.SessionRequest", - "x-validate": null - }, - "Split": { - "description": "A saved division of a transaction across budgets. Amounts are decimal strings.", - "properties": { - "archived_at": { - "format": "date-time", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "split_lines": { - "description": "Oldest first.", - "items": { - "$ref": "#/components/schemas/SplitLine" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "name", - "split_lines" - ], - "title": "Split", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.Split", - "x-validate": null - }, - "SplitLine": { - "description": "One budget's share of a split.", - "properties": { - "amount": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budget_id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "amount", - "budget_id" - ], - "title": "SplitLine", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.SplitLine", - "x-validate": null - }, - "SplitLineRequest": { - "description": "A line to keep, update or add. Distinct from SplitLine because `id` is optional.", - "properties": { - "amount": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budget_id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "id": { - "description": "Omit to add a new line.", - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "amount", - "budget_id" - ], - "title": "SplitLineRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.SplitLineRequest", - "x-validate": null - }, - "SplitRequest": { - "description": "Send the whole set of lines you want the split to end up with. A line with an `id` is kept and\nupdated, one without is added, and any line left out is deleted.\n", - "example": { - "name": "Payday", - "split_lines": [ - { - "amount": "-200.00", - "budget_id": "bgt_01j0rent" - } - ] - }, - "properties": { - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "split_lines": { - "items": { - "$ref": "#/components/schemas/SplitLineRequest" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - }, - "title": "SplitRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.SplitRequest", - "x-validate": null - }, - "Transaction": { - "description": "A movement of money. Amounts are decimal strings, negative for money going out.\n\n`budget_allocations` is what the server settled on, not what was sent: any part of the amount\nleft unallocated lands on the Spendable budget on every write. Render what comes back.\n", - "properties": { - "amount": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budget_allocations": { - "items": { - "$ref": "#/components/schemas/BudgetAllocation" - }, - "type": "array", - "x-struct": null, - "x-validate": null - }, - "date": { - "format": "date", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "excluded": { - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "note": { - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "reviewed": { - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "source": { - "nullable": true, - "oneOf": [ - { - "$ref": "#/components/schemas/TransactionSource" - } - ], - "x-struct": null, - "x-validate": null - }, - "transfer_id": { - "description": "The other side of a move between the user's own accounts.", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "name", - "amount", - "date", - "reviewed", - "excluded", - "budget_allocations" - ], - "title": "Transaction", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.Transaction", - "x-validate": null - }, - "TransactionRequest": { - "description": "Send the whole set of allocations you want. One with an `id` is kept and updated, one without\nis added, one left out is deleted. Whatever is left of the amount goes to Spendable, so the\nallocations in the response are the ones that count.\n\nThat also means a validation error's pointer indexes the list the server settled on, not the\none that was sent - match the offending line by its `budget_id`, not by position.\n", - "example": { - "amount": "-30.00", - "budget_allocations": [ - { - "amount": "-30.00", - "budget_id": "bgt_01j0groceries" - } - ], - "date": "2026-08-15", - "name": "Market" - }, - "properties": { - "amount": { - "description": "Negative for money going out.", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "budget_allocations": { - "items": { - "$ref": "#/components/schemas/BudgetAllocationRequest" - }, - "type": "array", - "x-struct": null, - "x-validate": null - }, - "date": { - "format": "date", - "type": "string", - "x-struct": null, - "x-validate": null - }, - "excluded": { - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "note": { - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "reviewed": { - "type": "boolean", - "x-struct": null, - "x-validate": null - } - }, - "title": "TransactionRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.TransactionRequest", - "x-validate": null - }, - "TransactionSource": { - "description": "Where a synced transaction came from, flattened for the list row. Null on a transaction the\nuser entered themselves. Fetch the logo from `/api/banks/{member_id}/logo`.\n", - "properties": { - "account_id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "account_name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "account_number": { - "description": "Masked, last digits only.", - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - }, - "member_has_logo": { - "type": "boolean", - "x-struct": null, - "x-validate": null - }, - "member_id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "member_name": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "pending": { - "type": "boolean", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "account_id", - "account_name", - "member_id", - "member_name", - "member_has_logo", - "pending" - ], - "title": "TransactionSource", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.TransactionSource", - "x-validate": null - }, - "TransferRequest": { - "description": "Exactly two transactions: one leaving an account and one arriving in another.", - "example": { - "transaction_ids": [ - "txn_01j0out", - "txn_01j0in" - ] - }, - "properties": { - "transaction_ids": { - "items": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "maxItems": 2, - "minItems": 2, - "type": "array", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "transaction_ids" - ], - "title": "TransferRequest", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.TransferRequest", - "x-validate": null - }, - "User": { - "description": "The signed-in user.", - "properties": { - "bank_limit": { - "description": "How many banks the user may connect.", - "type": "integer", - "x-struct": null, - "x-validate": null - }, - "id": { - "type": "string", - "x-struct": null, - "x-validate": null - }, - "identities": { - "description": "The ways this account can be signed into.", - "items": { - "$ref": "#/components/schemas/Identity" - }, - "type": "array", - "x-struct": null, - "x-validate": null - }, - "image": { - "nullable": true, - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - "required": [ - "id", - "bank_limit", - "identities" - ], - "title": "User", - "type": "object", - "x-struct": "Elixir.SpendableWeb.Api.Schemas.User", - "x-validate": null - } - }, - "securitySchemes": { - "bearer": { - "scheme": "bearer", - "type": "http" - } - } - }, - "info": { - "description": "The API the Spendable mobile app runs on.", - "title": "Spendable", - "version": "1.0.0" - }, - "openapi": "3.0.0", - "paths": { - "/api/bank_accounts/{id}": { - "patch": { - "callbacks": {}, - "operationId": "updateBankAccount", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BankAccountRequest" - } - } - }, - "description": "BankAccount", - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BankAccount" - } - } - }, - "description": "BankAccount" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Sync an account, or assign it to a budget", - "tags": [ - "banks" - ] - } - }, - "/api/banks": { - "get": { - "callbacks": {}, - "operationId": "listBanks", - "parameters": [ - { - "description": "Matches on institution name.", - "in": "query", - "name": "search", - "required": false, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/BankMember" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - } - }, - "description": "BankMembers" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "List connections and their accounts", - "tags": [ - "banks" - ] - }, - "post": { - "callbacks": {}, - "description": "Exchanges the public token Plaid Link returned. The accounts arrive on the first sync rather\nthan in this response, so the connection comes back with none.\n", - "operationId": "createBank", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConnectRequest" - } - } - }, - "description": "Public token", - "required": false - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BankMember" - } - } - }, - "description": "BankMember" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Finish connecting a bank", - "tags": [ - "banks" - ] - } - }, - "/api/banks/link_token": { - "post": { - "callbacks": {}, - "description": "Refused once the user is at their bank limit, before Plaid is called.", - "operationId": "createLinkToken", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LinkToken" - } - } - }, - "description": "LinkToken" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Start connecting a bank", - "tags": [ - "banks" - ] - } - }, - "/api/banks/{id}/link_token": { - "post": { - "callbacks": {}, - "description": "For a connection whose status is not CONNECTED, or to verify micro deposits.", - "operationId": "createUpdateLinkToken", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LinkToken" - } - } - }, - "description": "LinkToken" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Reopen an existing connection", - "tags": [ - "banks" - ] - } - }, - "/api/banks/{id}/logo": { - "get": { - "callbacks": {}, - "description": "Cacheable and ETagged, so a client can hold it on disk and revalidate for free.", - "operationId": "getBankLogo", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "image/png": { - "schema": { - "format": "binary", - "type": "string", - "x-struct": null, - "x-validate": null - } - } - }, - "description": "PNG" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "An institution's logo", - "tags": [ - "banks" - ] - } - }, - "/api/banks/{id}/sync": { - "post": { - "callbacks": {}, - "description": "Queues the work and returns immediately. There is no completion signal - refresh the lists to\npick up whatever has landed.\n", - "operationId": "syncBank", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "202": { - "content": { - "application/json": {} - }, - "description": "Queued" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Pull two years of history", - "tags": [ - "banks" - ] - } - }, - "/api/budgets": { - "get": { - "callbacks": {}, - "description": "Spendable first, then alphabetical. Archived budgets are left out.", - "operationId": "listBudgets", - "parameters": [ - { - "description": "Matches on name.", - "in": "query", - "name": "search", - "required": false, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Budget" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - } - }, - "description": "Budgets" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "List budgets", - "tags": [ - "budgets" - ] - }, - "post": { - "callbacks": {}, - "operationId": "createBudget", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BudgetRequest" - } - } - }, - "description": "Budget", - "required": false - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Budget" - } - } - }, - "description": "Budget" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Create a budget", - "tags": [ - "budgets" - ] - } - }, - "/api/budgets/summary": { - "get": { - "callbacks": {}, - "description": "Defaults to the current month. Any date in a month selects that whole month.", - "operationId": "getBudgetSummary", - "parameters": [ - { - "description": "", - "in": "query", - "name": "month", - "required": false, - "schema": { - "format": "date", - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - { - "description": "Matches on budget name.", - "in": "query", - "name": "search", - "required": false, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BudgetSummary" - } - } - }, - "description": "BudgetSummary" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "The budgets screen for one month", - "tags": [ - "budgets" - ] - } - }, - "/api/budgets/{id}": { - "delete": { - "callbacks": {}, - "description": "Budgets are archived rather than deleted, so the history they hold survives.", - "operationId": "archiveBudget", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Budget" - } - } - }, - "description": "Budget" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Archive a budget", - "tags": [ - "budgets" - ] - }, - "get": { - "callbacks": {}, - "operationId": "getBudget", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Budget" - } - } - }, - "description": "Budget" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Get a budget", - "tags": [ - "budgets" - ] - }, - "patch": { - "callbacks": {}, - "description": "The response carries the recalculated balance, so render it rather than the request.", - "operationId": "updateBudget", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BudgetRequest" - } - } - }, - "description": "Budget", - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Budget" - } - } - }, - "description": "Budget" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Update a budget", - "tags": [ - "budgets" - ] - } - }, - "/api/identities": { - "post": { - "callbacks": {}, - "description": "Attaches a second provider to the account making the request. Nothing about a person is stored\nthat would let the app match them across providers, so this is the only way two sign-in\nmethods reach one account - and it has to be done from inside it.\n", - "operationId": "createIdentity", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionRequest" - } - } - }, - "description": "Credentials", - "required": false - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Identity" - } - } - }, - "description": "Identity" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Add another way to sign in", - "tags": [ - "session" - ] - } - }, - "/api/identities/{id}": { - "delete": { - "callbacks": {}, - "description": "Refused for the last one, which would leave an account nobody can get back into.", - "operationId": "deleteIdentity", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "204": { - "content": { - "application/json": {} - }, - "description": "Removed" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Remove a way to sign in", - "tags": [ - "session" - ] - } - }, - "/api/me": { - "get": { - "callbacks": {}, - "description": "Carries the sign-in methods on the account, so the client can offer to add one.", - "operationId": "getCurrentUser", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - }, - "description": "User" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "The signed-in user", - "tags": [ - "session" - ] - } - }, - "/api/session": { - "delete": { - "callbacks": {}, - "description": "Revokes the token the request was made with.", - "operationId": "deleteSession", - "parameters": [], - "responses": { - "204": { - "content": { - "application/json": {} - }, - "description": "Signed out" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Sign out", - "tags": [ - "session" - ] - }, - "post": { - "callbacks": {}, - "description": "Exchanges an ID token from a native sign-in for an API token.", - "operationId": "createSession", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionRequest" - } - } - }, - "description": "Credentials", - "required": false - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - }, - "description": "Session" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "security": [], - "summary": "Sign in", - "tags": [ - "session" - ] - } - }, - "/api/splits": { - "get": { - "callbacks": {}, - "description": "Alphabetical, without their lines. Archived splits are left out.", - "operationId": "listSplits", - "parameters": [ - { - "description": "Matches on name.", - "in": "query", - "name": "search", - "required": false, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Split" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - } - }, - "description": "Splits" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "List splits", - "tags": [ - "splits" - ] - }, - "post": { - "callbacks": {}, - "operationId": "createSplit", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SplitRequest" - } - } - }, - "description": "Split", - "required": false - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Split" - } - } - }, - "description": "Split" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Create a split", - "tags": [ - "splits" - ] - } - }, - "/api/splits/{id}": { - "delete": { - "callbacks": {}, - "operationId": "archiveSplit", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Split" - } - } - }, - "description": "Split" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Archive a split", - "tags": [ - "splits" - ] - }, - "get": { - "callbacks": {}, - "operationId": "getSplit", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Split" - } - } - }, - "description": "Split" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Get a split and its lines", - "tags": [ - "splits" - ] - }, - "patch": { - "callbacks": {}, - "operationId": "updateSplit", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SplitRequest" - } - } - }, - "description": "Split", - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Split" - } - } - }, - "description": "Split" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Update a split", - "tags": [ - "splits" - ] - } - }, - "/api/transactions": { - "get": { - "callbacks": {}, - "description": "Newest first. Reviewed and excluded transactions are hidden unless asked for, because the list\nis a queue of what still needs attention. A page shorter than `per_page` is the last one.\n", - "operationId": "listTransactions", - "parameters": [ - { - "description": "Matches on name or note.", - "in": "query", - "name": "search", - "required": false, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - }, - { - "description": "", - "in": "query", - "name": "page", - "required": false, - "schema": { - "minimum": 1, - "type": "integer", - "x-struct": null, - "x-validate": null - } - }, - { - "description": "", - "in": "query", - "name": "per_page", - "required": false, - "schema": { - "maximum": 200, - "minimum": 1, - "type": "integer", - "x-struct": null, - "x-validate": null - } - }, - { - "description": "", - "in": "query", - "name": "show_reviewed", - "required": false, - "schema": { - "type": "boolean", - "x-struct": null, - "x-validate": null - } - }, - { - "description": "", - "in": "query", - "name": "show_excluded", - "required": false, - "schema": { - "type": "boolean", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Transaction" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - } - }, - "description": "Transactions" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "List transactions", - "tags": [ - "transactions" - ] - }, - "post": { - "callbacks": {}, - "description": "For money the user is recording themselves; synced activity arrives on its own.", - "operationId": "createTransaction", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TransactionRequest" - } - } - }, - "description": "Transaction", - "required": false - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - }, - "description": "Transaction" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Create a transaction", - "tags": [ - "transactions" - ] - } - }, - "/api/transactions/bulk": { - "patch": { - "callbacks": {}, - "description": "Applied one at a time rather than all or nothing, so a transaction that has since been deleted\ndoes not cost the rest their change. Check `failed` before assuming the whole set landed.\n", - "operationId": "updateTransactions", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkRequest" - } - } - }, - "description": "Change to apply", - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkResult" - } - } - }, - "description": "BulkResult" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Change several transactions at once", - "tags": [ - "transactions" - ] - } - }, - "/api/transactions/bulk/delete": { - "post": { - "callbacks": {}, - "operationId": "deleteTransactions", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkRequest" - } - } - }, - "description": "Transactions to delete", - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkResult" - } - } - }, - "description": "BulkResult" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Delete several transactions at once", - "tags": [ - "transactions" - ] - } - }, - "/api/transactions/transfer": { - "post": { - "callbacks": {}, - "description": "A transfer moves money between the user's own accounts rather than spending it, so the pair\nhas to be one transaction leaving an account and one arriving in another. Both sides come\nback, with their allocations cleared onto Spendable where the opposite signs cancel.\n", - "operationId": "createTransfer", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TransferRequest" - } - } - }, - "description": "Transactions to link", - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Transaction" - }, - "type": "array", - "x-struct": null, - "x-validate": null - } - } - }, - "description": "Both sides" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Link two transactions as a transfer", - "tags": [ - "transactions" - ] - } - }, - "/api/transactions/{id}": { - "delete": { - "callbacks": {}, - "description": "Its allocations go with it.", - "operationId": "deleteTransaction", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "204": { - "content": { - "application/json": {} - }, - "description": "Deleted" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Delete a transaction", - "tags": [ - "transactions" - ] - }, - "get": { - "callbacks": {}, - "operationId": "getTransaction", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - }, - "description": "Transaction" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Get a transaction", - "tags": [ - "transactions" - ] - }, - "patch": { - "callbacks": {}, - "description": "The response carries the allocations the server settled on. Render those.", - "operationId": "updateTransaction", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TransactionRequest" - } - } - }, - "description": "Transaction", - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - }, - "description": "Transaction" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Update a transaction", - "tags": [ - "transactions" - ] - } - }, - "/api/transactions/{id}/transfer": { - "delete": { - "callbacks": {}, - "description": "Both sides count toward budgets again. Their allocations stay where they are.", - "operationId": "deleteTransfer", - "parameters": [ - { - "description": "", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - "x-struct": null, - "x-validate": null - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Transaction" - } - } - }, - "description": "Transaction" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Errors" - } - } - }, - "description": "Errors" - } - }, - "summary": "Unlink a transfer", - "tags": [ - "transactions" - ] - } - } - }, - "security": [ - { - "bearer": [] - } - ], - "servers": [ - { - "url": "https://spendable.money", - "variables": {} - } - ], - "tags": [] -} diff --git a/priv/static/openapi.json b/priv/static/openapi.json index b652d99d..04eff22c 100644 --- a/priv/static/openapi.json +++ b/priv/static/openapi.json @@ -681,6 +681,27 @@ "x-struct": "Elixir.SpendableWeb.Api.Schemas.SessionRequest", "x-validate": null }, + "SessionUpdateRequest": { + "description": "The device this token was issued to, so the server can push to it.", + "properties": { + "apns_token": { + "description": "The hex device token iOS handed the app.", + "maxLength": 256, + "minLength": 64, + "pattern": "\\A[0-9a-f]+\\z", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "apns_token" + ], + "title": "SessionUpdateRequest", + "type": "object", + "x-struct": "Elixir.SpendableWeb.Api.Schemas.SessionUpdateRequest", + "x-validate": null + }, "Split": { "description": "A saved division of a transaction across budgets. Amounts are decimal strings.", "properties": { @@ -1935,6 +1956,55 @@ "session" ] }, + "patch": { + "callbacks": {}, + "description": "Sends the APNs device token iOS issued the app. It is held against the token the request was\nmade with, so signing out stops the pushes with it.\n", + "operationId": "updateSession", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionUpdateRequest" + } + } + }, + "description": "Device token", + "required": false + }, + "responses": { + "204": { + "content": { + "application/json": {} + }, + "description": "Registered" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Errors" + } + } + }, + "description": "Errors" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Errors" + } + } + }, + "description": "Errors" + } + }, + "summary": "Register this device for push", + "tags": [ + "session" + ] + }, "post": { "callbacks": {}, "description": "Exchanges an ID token from a native sign-in for an API token.", diff --git a/scripts/install.sh b/scripts/install.sh index 5bac5dc5..e82754bc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -54,6 +54,26 @@ ask_secret() { write_secret "$name" "$value" } +# A .p8 signing key is a file rather than a line, so it is copied in rather than typed. A blank +# answer leaves push unconfigured, which the app runs fine without. $1 name, $2 prompt. +ask_secret_file() { + local name="$1" label="$2" suffix="" path="" + + [ -s "$SECRETS/$name" ] && suffix=" [keep existing]" + + read -rp "$label$suffix: " path + + # Strip the quotes a file dragged into the terminal arrives wrapped in, then expand a leading ~. + path="${path%\"}" + path="${path#\"}" + path="${path/#\~/$HOME}" + + [ -n "$path" ] || return 0 + [ -f "$path" ] || { echo "$path is not a file" >&2; exit 1; } + + install -m 600 "$path" "$SECRETS/$name" +} + # A public identifier rather than a credential, so it stays out of .secrets and lands in # .env.prod, which is where compose reads it from. Answers may be empty. $1 name, $2 prompt. ask_public() { @@ -87,6 +107,18 @@ echo "Plaid (dashboard.plaid.com)" ask_secret PLAID_CLIENT_ID " Client ID" ask_secret PLAID_SECRET_KEY " Secret key" hidden +echo +echo "Apple push notifications (developer.apple.com > Keys, an APNs key)" +echo " Skip the key path to leave push off; everything else runs without it." +# All three live in .secrets rather than .env.prod: the key id and team id are useless without the +# key, and keeping the set together is what makes it obvious when one is missing. +ask_secret_file APNS_PRIVATE_KEY " Path to the .p8 key file" + +if [ -s "$SECRETS/APNS_PRIVATE_KEY" ]; then + ask_secret APNS_KEY_ID " Key ID" + ask_secret APNS_TEAM_ID " Team ID" +fi + echo echo "Database" ask_secret DB_PASSWORD " Postgres password" hidden postgres