From 9ff8cdf6f12dbe9447449b90604580c6efc7f82f Mon Sep 17 00:00:00 2001 From: Nick Kezhaya Date: Wed, 16 Oct 2019 17:51:37 -0500 Subject: [PATCH] Initial payment intents API complete --- config/test.exs | 6 +- lib/stripe_mock/api.ex | 1 - lib/stripe_mock/api/charge.ex | 8 +- lib/stripe_mock/api/operations/card.ex | 8 +- lib/stripe_mock/api/operations/charge.ex | 6 +- .../api/operations/payment_intent.ex | 38 ++++- lib/stripe_mock/api/operations/token.ex | 8 +- lib/stripe_mock/api/payment_intent.ex | 66 +++++++-- lib/stripe_mock/api/payment_method.ex | 19 +++ lib/stripe_mock/schema.ex | 12 +- .../controllers/payment_intent_controller.ex | 24 ++-- .../views/payment_intent_view.ex | 29 ++++ .../views/payment_method_view.ex | 30 ++++ .../20191015221147_create_tables.exs | 27 +++- .../controllers/charge_controller_test.exs | 5 + .../payment_intent_controller_test.exs | 133 ++++++++++++++++++ test/support/conn_case.ex | 3 + test/test_helper.exs | 18 +++ 18 files changed, 392 insertions(+), 49 deletions(-) create mode 100644 lib/stripe_mock/api/payment_method.ex create mode 100644 lib/stripe_mock_web/views/payment_intent_view.ex create mode 100644 lib/stripe_mock_web/views/payment_method_view.ex create mode 100644 test/stripe_mock_web/controllers/payment_intent_controller_test.exs diff --git a/config/test.exs b/config/test.exs index d03ae72..6d92fbf 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,7 +1,5 @@ use Mix.Config -config :stripe_mock, StripeMockWeb.Endpoint, - server: false - -# Print only warnings and errors during test +config :stripe_mock, StripeMockWeb.Endpoint, server: false +config :stripe_mock, StripeMock.Repo, pool: Ecto.Adapters.SQL.Sandbox config :logger, level: :warn diff --git a/lib/stripe_mock/api.ex b/lib/stripe_mock/api.ex index f601d0b..dbacb87 100644 --- a/lib/stripe_mock/api.ex +++ b/lib/stripe_mock/api.ex @@ -29,7 +29,6 @@ defmodule StripeMock.API do # PaymentIntents defdelegate list_payment_intents(), to: Ops.PaymentIntent defdelegate get_payment_intent(id), to: Ops.PaymentIntent - defdelegate get_payment_intent!(id), to: Ops.PaymentIntent defdelegate create_payment_intent(attrs \\ %{}), to: Ops.PaymentIntent defdelegate update_payment_intent(payment_intent, attrs \\ %{}), to: Ops.PaymentIntent defdelegate confirm_payment_intent(payment_intent), to: Ops.PaymentIntent diff --git a/lib/stripe_mock/api/charge.ex b/lib/stripe_mock/api/charge.ex index 7cf1340..da78851 100644 --- a/lib/stripe_mock/api/charge.ex +++ b/lib/stripe_mock/api/charge.ex @@ -5,17 +5,17 @@ defmodule StripeMock.API.Charge do field :amount, :integer field :capture, :boolean, default: false field :currency, :string - field :description, :string - field :metadata, :map, default: %{} field :statement_descriptor, :string field :transfer_group, :string field :source, :string, virtual: true + belongs_to :payment_intent, API.PaymentIntent belongs_to :customer, API.Customer belongs_to :card, API.Card belongs_to :token, API.Token + common_fields() timestamps() end @@ -50,7 +50,9 @@ defmodule StripeMock.API.Charge do end @doc false - def capture_changeset(payment_intent, charge) do + def capture_changeset(charge, payment_intent) do + charge + |> change(%{payment_intent_id: payment_intent.id}) end defp set_customer_and_source(changeset) do diff --git a/lib/stripe_mock/api/operations/card.ex b/lib/stripe_mock/api/operations/card.ex index 5e0a136..af27b46 100644 --- a/lib/stripe_mock/api/operations/card.ex +++ b/lib/stripe_mock/api/operations/card.ex @@ -2,7 +2,7 @@ defmodule StripeMock.API.Operations.Card do import Ecto.Query alias Ecto.Multi alias StripeMock.Repo - alias StripeMock.API.{Card, Source} + alias StripeMock.API.{Card, PaymentMethod} def list_cards(customer) do Card @@ -17,9 +17,9 @@ defmodule StripeMock.API.Operations.Card do def create_card(customer, attrs) do Multi.new() |> Multi.insert(:card, Card.create_changeset(%Card{customer_id: customer.id}, attrs)) - |> Multi.run(:source, fn _repo, %{card: card} -> - %Source{} - |> Source.changeset(%{card_id: card.id}) + |> Multi.run(:payment_method, fn _repo, %{card: card} -> + %PaymentMethod{} + |> PaymentMethod.changeset(%{card_id: card.id}) |> Repo.insert() end) |> Repo.transaction() diff --git a/lib/stripe_mock/api/operations/charge.ex b/lib/stripe_mock/api/operations/charge.ex index 3767c27..c2c8292 100644 --- a/lib/stripe_mock/api/operations/charge.ex +++ b/lib/stripe_mock/api/operations/charge.ex @@ -9,9 +9,9 @@ defmodule StripeMock.API.Operations.Charge do end def get_charge(id) do - with {:ok, charge} <- Repo.fetch(Charge, id) do - {:ok, preload_source(charge)} - end + Charge + |> Repo.fetch(id) + |> preload_source() end def create_charge(attrs \\ %{}) do diff --git a/lib/stripe_mock/api/operations/payment_intent.ex b/lib/stripe_mock/api/operations/payment_intent.ex index 01bca31..9ff973f 100644 --- a/lib/stripe_mock/api/operations/payment_intent.ex +++ b/lib/stripe_mock/api/operations/payment_intent.ex @@ -1,40 +1,68 @@ defmodule StripeMock.API.Operations.PaymentIntent do + import Ecto.Query + alias StripeMock.Repo alias StripeMock.API.{Charge, PaymentIntent} + @preload [payment_method: [:card, :source, token: [:card]]] + def list_payment_intents() do - Repo.all(PaymentIntent) + PaymentIntent + |> preload(^@preload) + |> Repo.all() end - def get_payment_intent(id), do: Repo.fetch(PaymentIntent, id) - def get_payment_intent!(id), do: Repo.get!(PaymentIntent, id) + def get_payment_intent(id) do + PaymentIntent + |> preload(^@preload) + |> Repo.fetch(id) + |> preload_payment_method() + end - def create_payment_intent(attrs \\ %{}) do + def create_payment_intent(attrs) do %PaymentIntent{} |> PaymentIntent.changeset(attrs) |> Repo.insert() + |> preload_payment_method() end def update_payment_intent(%PaymentIntent{} = payment_intent, attrs) do payment_intent |> PaymentIntent.changeset(attrs) |> Repo.update() + |> preload_payment_method() end def confirm_payment_intent(%PaymentIntent{} = payment_intent) do payment_intent |> PaymentIntent.status_changeset("requires_capture") |> Repo.update() + |> preload_payment_method() end def capture_payment_intent(%PaymentIntent{} = payment_intent) do charge = %Charge{} - |> Charge.payment_intent_changeset(payment_intent) + |> Charge.capture_changeset(payment_intent) |> Repo.insert!() payment_intent |> PaymentIntent.capture_changeset(charge) |> Repo.update!() + |> preload_payment_method() + end + + defp preload_payment_method({:ok, payment_intent}) do + {:ok, preload_payment_method(payment_intent)} end + + defp preload_payment_method(%PaymentIntent{} = payment_intent) do + Repo.preload(payment_intent, @preload) + end + + defp preload_payment_method([_ | _] = payment_intents) do + Repo.preload(payment_intents, @preload) + end + + defp preload_payment_method(arg), do: arg end diff --git a/lib/stripe_mock/api/operations/token.ex b/lib/stripe_mock/api/operations/token.ex index f12e1ca..a599b16 100644 --- a/lib/stripe_mock/api/operations/token.ex +++ b/lib/stripe_mock/api/operations/token.ex @@ -3,7 +3,7 @@ defmodule StripeMock.API.Operations.Token do alias Ecto.Multi alias StripeMock.Repo - alias StripeMock.API.{Token, Source} + alias StripeMock.API.{Token, PaymentMethod} def get_token(id) do Token @@ -14,9 +14,9 @@ defmodule StripeMock.API.Operations.Token do def create_token(attrs) do Multi.new() |> Multi.insert(:token, Token.changeset(%Token{}, attrs)) - |> Multi.run(:source, fn _repo, %{token: token} -> - %Source{} - |> Source.changeset(%{token_id: token.id}) + |> Multi.run(:payment_method, fn _repo, %{token: token} -> + %PaymentMethod{} + |> PaymentMethod.changeset(%{token_id: token.id}) |> Repo.insert() end) |> Repo.transaction() diff --git a/lib/stripe_mock/api/payment_intent.ex b/lib/stripe_mock/api/payment_intent.ex index 610648c..e9f9357 100644 --- a/lib/stripe_mock/api/payment_intent.ex +++ b/lib/stripe_mock/api/payment_intent.ex @@ -1,32 +1,82 @@ defmodule StripeMock.API.PaymentIntent do use StripeMock.Schema - @primary_key {:id, :binary_id, autogenerate: false} schema "payment_intents" do field :amount, :integer - field :capture, :boolean, default: false - field :capture_method, :string - field :confirmation_method, :string + field :capture_method, :string, default: "automatic" + field :confirm, :boolean, virtual: true, default: false + field :confirmation_method, :string, default: "automatic" field :currency, :string - field :description, :string - field :metadata, StripeMock.Type.Metadata, default: %{} field :payment_method_types, {:array, :string}, default: ["card"] field :statement_descriptor, :string + field :status, :string + field :transfer_data, :map field :transfer_group, :string + + belongs_to(:customer, API.Customer) + belongs_to(:payment_method, API.PaymentMethod) + has_many(:charges, API.Charge) + + common_fields() + timestamps() end @doc false def changeset(payment_intent, attrs) do payment_intent - |> cast(attrs, [:amount, :confirm, :confirmation_method, :currency]) + |> cast(attrs, [ + :amount, + :confirm, + :confirmation_method, + :currency, + :customer_id, + :description, + :metadata, + :payment_method_id, + :statement_descriptor, + :transfer_data, + :transfer_group + ]) + |> validate_inclusion(:capture_method, ~w(automatic manual)) + |> validate_inclusion(:confirmation_method, ~w(automatic manual)) + |> set_payment_method() + |> validate_required([:payment_method_id]) + |> put_common_fields() end @doc false def status_changeset(payment_intent, status) do - change(payment_intent, %{status: status}) + payment_intent + |> change(%{status: status}) + |> put_common_fields() end @doc false def capture_changeset(payment_intent, charge) do end + + defp set_payment_method(changeset) do + case get_change(changeset, :payment_method_id) do + nil -> + changeset + + id -> + card = Repo.get(API.Card, id) + token = Repo.get(API.Token, id) + + case find_payment_method(card || token) do + nil -> add_error(changeset, :payment_method_id, "not found") + payment_method -> put_change(changeset, :payment_method_id, payment_method.id) + end + end + end + + defp find_payment_method(%API.Card{} = card), + do: Repo.get_by(API.PaymentMethod, card_id: card.id) + + defp find_payment_method(%API.Token{} = token), + do: Repo.get_by(API.PaymentMethod, token_id: token.id) + + defp find_payment_method(_), + do: nil end diff --git a/lib/stripe_mock/api/payment_method.ex b/lib/stripe_mock/api/payment_method.ex new file mode 100644 index 0000000..9a41651 --- /dev/null +++ b/lib/stripe_mock/api/payment_method.ex @@ -0,0 +1,19 @@ +defmodule StripeMock.API.PaymentMethod do + use StripeMock.Schema + + schema "payment_methods" do + belongs_to :card, API.Card + belongs_to :token, API.Token + belongs_to :source, API.Source + + common_fields() + timestamps() + end + + @doc false + def changeset(token, attrs) do + token + |> cast(attrs, [:card_id, :token_id, :source_id]) + |> put_common_fields() + end +end diff --git a/lib/stripe_mock/schema.ex b/lib/stripe_mock/schema.ex index e232b1c..688b7e0 100644 --- a/lib/stripe_mock/schema.ex +++ b/lib/stripe_mock/schema.ex @@ -13,12 +13,16 @@ defmodule StripeMock.Schema do end end + defmacro common_fields() do + quote do + field :description, :string + field :metadata, :map, default: %{} + end + end + import Ecto.Changeset def put_common_fields(changeset) do - case get_field(changeset, :metadata) do - nil -> put_change(changeset, :metadata, %{}) - _ -> changeset - end + validate_required(changeset, [:metadata]) end end diff --git a/lib/stripe_mock_web/controllers/payment_intent_controller.ex b/lib/stripe_mock_web/controllers/payment_intent_controller.ex index 70ffff2..3dda1e6 100644 --- a/lib/stripe_mock_web/controllers/payment_intent_controller.ex +++ b/lib/stripe_mock_web/controllers/payment_intent_controller.ex @@ -2,7 +2,10 @@ defmodule StripeMockWeb.PaymentIntentController do use StripeMockWeb, :controller alias StripeMock.API - alias StripeMock.API.PaymentIntent + + plug SMPlug.ConvertParams, + %{"customer" => "customer_id", "payment_method" => "payment_method_id"} + when action in [:create, :update] action_fallback StripeMockWeb.FallbackController @@ -27,26 +30,23 @@ defmodule StripeMockWeb.PaymentIntentController do end def update(conn, %{"id" => id} = payment_intent_params) do - payment_intent = API.get_payment_intent!(id) - - with {:ok, payment_intent} <- + with {:ok, payment_intent} <- API.get_payment_intent(id), + {:ok, payment_intent} <- API.update_payment_intent(payment_intent, payment_intent_params) do render(conn, "show.json", payment_intent: payment_intent) end end - def confirm(conn, %{"id" => id} = payment_intent_params) do - payment_intent = API.get_payment_intent!(id) - - with {:ok, payment_intent} <- API.confirm_payment_intent(payment_intent) do + def confirm(conn, %{"id" => id}) do + with {:ok, payment_intent} <- API.get_payment_intent(id), + {:ok, payment_intent} <- API.confirm_payment_intent(payment_intent) do render(conn, "show.json", payment_intent: payment_intent) end end - def capture(conn, %{"id" => id} = payment_intent_params) do - payment_intent = API.get_payment_intent!(id) - - with {:ok, payment_intent} <- API.capture_payment_intent(payment_intent) do + def capture(conn, %{"id" => id}) do + with {:ok, payment_intent} <- API.get_payment_intent(id), + {:ok, payment_intent} <- API.capture_payment_intent(payment_intent) do render(conn, "show.json", payment_intent: payment_intent) end end diff --git a/lib/stripe_mock_web/views/payment_intent_view.ex b/lib/stripe_mock_web/views/payment_intent_view.ex new file mode 100644 index 0000000..eaa599f --- /dev/null +++ b/lib/stripe_mock_web/views/payment_intent_view.ex @@ -0,0 +1,29 @@ +defmodule StripeMockWeb.PaymentIntentView do + use StripeMockWeb, :view + alias StripeMockWeb.PaymentIntentView + + def render("index.json", %{page: page}) do + %{data: render_many(page.data, PaymentIntentView, "payment_intent.json")} + end + + def render("show.json", %{payment_intent: payment_intent}) do + render_one(payment_intent, PaymentIntentView, "payment_intent.json") + end + + def render("payment_intent.json", %{payment_intent: payment_intent}) do + payment_intent + |> as_map() + |> Map.take( + ~w(amount capture_method confirmation_method currency description id metadata payment_method_types statement_descriptor status transfer_group)a + ) + |> Map.put("customer", payment_intent.customer_id) + |> Map.merge(%{ + object: "payment_intent", + payment_method: render_payment_method(payment_intent.payment_method) + }) + end + + def render_payment_method(payment_method) do + render(StripeMockWeb.PaymentMethodView, "payment_method.json", payment_method: payment_method) + end +end diff --git a/lib/stripe_mock_web/views/payment_method_view.ex b/lib/stripe_mock_web/views/payment_method_view.ex new file mode 100644 index 0000000..1461900 --- /dev/null +++ b/lib/stripe_mock_web/views/payment_method_view.ex @@ -0,0 +1,30 @@ +defmodule StripeMockWeb.PaymentMethodView do + use StripeMockWeb, :view + alias StripeMockWeb.PaymentMethodView + + def render("index.json", %{page: page}) do + %{data: render_many(page.data, PaymentMethodView, "payment_method.json")} + end + + def render("show.json", %{payment_method: payment_method}) do + render_one(payment_method, PaymentMethodView, "payment_method.json") + end + + def render("payment_method.json", %{payment_method: payment_method}) do + card = payment_method.card || payment_method.token.card + payment_object = render(StripeMockWeb.CardView, "card.json", card: card) + + payment_method + |> Map.take(~w(id created description metadata)a) + |> as_map() + |> Map.put(:card, payment_object) + end + + def do_render(%API.PaymentMethod{card: card}) when not is_nil(card) do + do_render(card) + end + + def do_render(%API.PaymentMethod{token: token}) when not is_nil(token) do + do_render(token.card) + end +end diff --git a/priv/repo/migrations/20191015221147_create_tables.exs b/priv/repo/migrations/20191015221147_create_tables.exs index dabfa63..f936e0f 100644 --- a/priv/repo/migrations/20191015221147_create_tables.exs +++ b/priv/repo/migrations/20191015221147_create_tables.exs @@ -4,7 +4,7 @@ defmodule StripeMock.Repo.Migrations.CreateTables do defmacro common_fields() do quote do add(:deleted, :boolean, null: false, default: false) - add(:description, :string) + add(:description, :string, null: true) add(:metadata, :map, null: false, default: %{}) timestamps(inserted_at: :created, updated_at: false) end @@ -46,8 +46,32 @@ defmodule StripeMock.Repo.Migrations.CreateTables do end create table(:sources) do + common_fields() + end + + create table(:payment_methods) do add(:card_id, references(:cards)) add(:token_id, references(:tokens)) + add(:source_id, references(:sources)) + + common_fields() + end + + create table(:payment_intents) do + add(:amount, :integer) + add(:capture_method, :string) + add(:confirmation_method, :string) + add(:currency, :string) + add(:payment_method_types, {:array, :string}, default: ["card"]) + add(:statement_descriptor, :string) + add(:status, :string) + add(:transfer_data, :map) + add(:transfer_group, :string) + + add(:customer_id, references(:customers)) + add(:payment_method_id, references(:payment_methods)) + + common_fields() end create table(:charges) do @@ -60,6 +84,7 @@ defmodule StripeMock.Repo.Migrations.CreateTables do add(:customer_id, references(:customers)) add(:card_id, references(:cards)) add(:token_id, references(:tokens)) + add(:payment_intent_id, references(:payment_intents)) common_fields() end diff --git a/test/stripe_mock_web/controllers/charge_controller_test.exs b/test/stripe_mock_web/controllers/charge_controller_test.exs index eb77903..900e0de 100644 --- a/test/stripe_mock_web/controllers/charge_controller_test.exs +++ b/test/stripe_mock_web/controllers/charge_controller_test.exs @@ -87,6 +87,11 @@ defmodule StripeMockWeb.ChargeControllerTest do "metadata" => %{"key" => "val"} } = json_response(conn, 200) end + + test "renders errors when data is invalid", %{conn: conn, charge: charge} do + conn = patch(conn, Routes.charge_path(conn, :update, charge), invalid_attrs()) + assert json_response(conn, 422)["errors"] != %{} + end end def create_attrs() do diff --git a/test/stripe_mock_web/controllers/payment_intent_controller_test.exs b/test/stripe_mock_web/controllers/payment_intent_controller_test.exs new file mode 100644 index 0000000..980a52e --- /dev/null +++ b/test/stripe_mock_web/controllers/payment_intent_controller_test.exs @@ -0,0 +1,133 @@ +defmodule StripeMockWeb.PaymentIntentControllerTest do + use StripeMockWeb.ConnCase + @moduletag :payment_intent + + alias StripeMock.API.PaymentIntent + + setup :create_customer + setup :create_card + + describe "index" do + setup :create_payment_intent + + test "lists all payment intents", %{conn: conn} do + conn = get(conn, Routes.payment_intent_path(conn, :index)) + assert is_list(json_response(conn, 200)["data"]) + end + end + + describe "create payment intent" do + setup :create_token + + test "renders payment intent when the customer and card are valid", %{ + conn: conn, + customer: customer, + token: token + } do + params = create_attrs() |> Map.merge(%{customer_id: customer.id, payment_method: token.id}) + + conn = post(conn, Routes.payment_intent_path(conn, :create), params) + assert %{"id" => id} = json_response(conn, 201) + + conn = get(conn, Routes.payment_intent_path(conn, :show, id)) + + assert %{ + "id" => id, + "amount" => 5000, + "currency" => "some currency", + "customer" => _, + "description" => "some description", + "metadata" => %{}, + "statement_descriptor" => "some statement_descriptor", + "transfer_group" => "some transfer_group" + } = json_response(conn, 200) + end + + test "renders payment intent when the token is valid and no customer is provided", %{ + conn: conn, + token: token + } do + params = create_attrs() |> Map.merge(%{payment_method: token.id}) + conn = post(conn, Routes.payment_intent_path(conn, :create), params) + assert %{"id" => id} = json_response(conn, 201) + + conn = get(conn, Routes.payment_intent_path(conn, :show, id)) + + assert %{ + "id" => id, + "amount" => 5000, + "currency" => "some currency", + "customer" => nil, + "description" => "some description", + "metadata" => %{}, + "statement_descriptor" => "some statement_descriptor", + "transfer_group" => "some transfer_group" + } = json_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.payment_intent_path(conn, :create), invalid_attrs()) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update payment intent" do + setup [:create_payment_intent] + + test "renders payment intent when data is valid", %{ + conn: conn, + payment_intent: payment_intent + } do + %PaymentIntent{id: id} = payment_intent + conn = put(conn, Routes.payment_intent_path(conn, :update, payment_intent), update_attrs()) + assert %{"id" => ^id} = json_response(conn, 200) + + conn = get(conn, Routes.payment_intent_path(conn, :show, id)) + + assert %{ + "description" => "some updated description", + "metadata" => %{"key" => "val"} + } = json_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn, payment_intent: payment_intent} do + conn = + patch(conn, Routes.payment_intent_path(conn, :update, payment_intent), invalid_attrs()) + + assert json_response(conn, 422)["errors"] != %{} + end + end + + def create_attrs() do + %{ + amount: 5000, + capture: true, + currency: "some currency", + description: "some description", + metadata: %{}, + statement_descriptor: "some statement_descriptor", + transfer_group: "some transfer_group" + } + end + + def update_attrs() do + %{ + description: "some updated description", + metadata: %{"key" => "val"} + } + end + + def invalid_attrs() do + %{ + amount: nil, + capture: nil, + currency: nil, + customer: nil, + description: nil, + metadata: nil, + payment_method: nil, + statement_descriptor: nil, + transfer_group: nil + } + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 6906c9a..59c44fa 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -28,6 +28,9 @@ defmodule StripeMockWeb.ConnCase do end setup _tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(StripeMock.Repo) + Ecto.Adapters.SQL.Sandbox.mode(StripeMock.Repo, {:shared, self()}) + {:ok, conn: Phoenix.ConnTest.build_conn()} end diff --git a/test/test_helper.exs b/test/test_helper.exs index 6ba4291..dc7e773 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -45,6 +45,24 @@ defmodule StripeMock.TestHelper do charge end + def create_payment_intent(%{customer: customer}) do + [payment_intent: create_payment_intent(customer)] + end + + def create_payment_intent(%API.Customer{} = customer) do + [token: token] = create_token() + + {:ok, charge} = + API.create_payment_intent(%{ + amount: 5000, + currency: "some currency", + customer_id: customer.id, + payment_method_id: token.id + }) + + charge + end + def create_refund(%{charge: charge}) do params = %{ amount: 5000,