From 2f36082f9093d281ed572f881cd485ffc5205177 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Mon, 3 Feb 2025 17:49:36 +0100 Subject: [PATCH 1/4] added PaymentCollection and Capture to purchaseUnit --- lib/paypal/order/purchase_unit.ex | 6 +- lib/paypal/order/purchase_unit/capture.ex | 77 +++++++++++++++++++ .../order/purchase_unit/payment_collection.ex | 32 ++++++++ test/integration_test.exs | 53 ++++++------- 4 files changed, 139 insertions(+), 29 deletions(-) create mode 100644 lib/paypal/order/purchase_unit/capture.ex create mode 100644 lib/paypal/order/purchase_unit/payment_collection.ex diff --git a/lib/paypal/order/purchase_unit.ex b/lib/paypal/order/purchase_unit.ex index 6ea6412..3ddbe4d 100644 --- a/lib/paypal/order/purchase_unit.ex +++ b/lib/paypal/order/purchase_unit.ex @@ -9,6 +9,7 @@ defmodule Paypal.Order.PurchaseUnit do alias Paypal.Common.CurrencyValue alias Paypal.Order.PurchaseUnit.Item + alias Paypal.Order.PurchaseUnit.PaymentCollection @derive Jason.Encoder @@ -27,8 +28,7 @@ defmodule Paypal.Order.PurchaseUnit do field(:soft_descriptor, :string) embeds_many(:items, Item) embeds_one(:amount, CurrencyValue) - # TODO - field(:payments, :map) + embeds_one(:payments, PaymentCollection) # TODO field(:payee, :map) # TODO @@ -49,7 +49,6 @@ defmodule Paypal.Order.PurchaseUnit do payment_instruction shipping supplementary_data - payments ]a @doc false @@ -58,6 +57,7 @@ defmodule Paypal.Order.PurchaseUnit do |> cast(params, @fields) |> cast_embed(:amount, required: true) |> cast_embed(:items) + |> cast_embed(:payments) |> validate_length(:reference_id, min: 1, max: 256) |> validate_length(:description, min: 1, max: 127) |> validate_length(:custom_id, min: 1, max: 127) diff --git a/lib/paypal/order/purchase_unit/capture.ex b/lib/paypal/order/purchase_unit/capture.ex new file mode 100644 index 0000000..8d193b9 --- /dev/null +++ b/lib/paypal/order/purchase_unit/capture.ex @@ -0,0 +1,77 @@ +defmodule Paypal.Order.PurchaseUnit.Capture do + @moduledoc """ + Represents a Capture object from the PayPal v2 PurchaseUnit API. + + ## Fields + + - `id` - The unique ID for the capture. + - `status` - The status of the capture (e.g. `"COMPLETED"`). + - `status_details` - The details of the capture status. + - `invoice_id` - The API caller-provided external invoice number for this order. + - `custom_id` - The API caller-provided external ID. + - `final_capture` - A boolean indicating if this is the final capture. + - `create_time` - The date and time when the capture was created (ISO 8601 string). + - `update_time` - The date and time when the capture was last updated (ISO 8601 string). + - `amount` - An embedded schema representing the monetary amount of the capture. + - `disbursement_mode` - An embedded schema containing details about the disbursement mode. + - `processor_response` - An embedded schema containing details about the processor response. + - `seller_protection` - An embedded schema containing details about seller protection. + - `seller_receivable_breakdown` - An embedded schema that details the receivables. + - `network_transaction_reference` - Reference values used by the card network to identify a transaction. + - `links` - A list of embedded link objects for further API actions. + """ + + use TypedEctoSchema + import Ecto.Changeset + alias Paypal.Common.CurrencyValue + alias Paypal.Common.Link + + @primary_key false + typed_embedded_schema do + field(:id, :string) + field(:status, :string) + field(:status_details, :string) + field(:invoice_id, :string) + field(:custom_id, :string) + field(:final_capture, :boolean) + field(:create_time, :string) + field(:update_time, :string) + # TODO + field(:seller_protection, :map) + # TODO + field(:seller_receivable_breakdown, :map) + # TODO + field(:network_transaction_reference, :map) + # TODO + field(:disbursement_mode, :map) + # TODO + field(:processor_response, :map) + + embeds_one(:amount, CurrencyValue) + embeds_many(:links, Link) + end + + @fields ~w[ + status + status_details + id + invoice_id + custom_id + network_transaction_reference + seller_protection + final_capture + seller_receivable_breakdown + disbursement_mode + processor_response + create_time + update_time + ]a + + @doc false + def changeset(model \\ %__MODULE__{}, params) do + model + |> cast(params, @fields) + |> cast_embed(:amount, required: true) + |> cast_embed(:links) + end +end diff --git a/lib/paypal/order/purchase_unit/payment_collection.ex b/lib/paypal/order/purchase_unit/payment_collection.ex new file mode 100644 index 0000000..cecdcee --- /dev/null +++ b/lib/paypal/order/purchase_unit/payment_collection.ex @@ -0,0 +1,32 @@ +defmodule Paypal.Order.PurchaseUnit.PaymentCollection do + @moduledoc """ + Represents a Payment Collection object from the PayPal v2 API. + + This object holds details about payment captures, refunds, and authorizations. + """ + + use TypedEctoSchema + import Ecto.Changeset + + alias Paypal.Order.PurchaseUnit.Capture + + typed_embedded_schema do + embeds_many(:captures, Capture) + # TODO + field(:authorizations, :map) + # TODO + field(:refunds, :map) + end + + @fields ~w[ + authorizations + refunds + ]a + + @doc false + def changeset(model \\ %__MODULE__{}, params) do + model + |> cast(params, @fields) + |> cast_embed(:captures, with: &Capture.changeset/2) + end +end diff --git a/test/integration_test.exs b/test/integration_test.exs index 46cef6f..bc304a8 100644 --- a/test/integration_test.exs +++ b/test/integration_test.exs @@ -1136,44 +1136,45 @@ defmodule Paypal.IntegrationTest do }, purchase_units: [ %Paypal.Order.PurchaseUnit{ - payments: %{ - "captures" => [ - %{ - "amount" => %{"currency_code" => "EUR", "value" => "10.00"}, - "create_time" => "2024-05-10T12:19:16Z", - "final_capture" => true, - "id" => "58A90337V3530010E", - "links" => [ - %{ - "href" => - "https://api.sandbox.paypal.com/v2/payments/captures/58A90337V3530010E", - "method" => "GET", - "rel" => "self" + payments: %Paypal.Order.PurchaseUnit.PaymentCollection{ + captures: [ + %Paypal.Order.PurchaseUnit.Capture{ + amount: %Paypal.Common.CurrencyValue{ + currency_code: "EUR", + value: Decimal.new("10.00") + }, + create_time: "2024-05-10T12:19:16Z", + final_capture: true, + id: "58A90337V3530010E", + links: [ + %Paypal.Common.Link{ + href: "https://api.sandbox.paypal.com/v2/payments/captures/58A90337V3530010E", + method: :get, + rel: "self" }, - %{ - "href" => + %Paypal.Common.Link{ + href: "https://api.sandbox.paypal.com/v2/payments/captures/58A90337V3530010E/refund", - "method" => "POST", - "rel" => "refund" + method: :post, + rel: "refund" }, - %{ - "href" => - "https://api.sandbox.paypal.com/v2/checkout/orders/7D653782TH669712A", - "method" => "GET", - "rel" => "up" + %Paypal.Common.Link{ + href: "https://api.sandbox.paypal.com/v2/checkout/orders/7D653782TH669712A", + method: :get, + rel: "up" } ], - "seller_protection" => %{ + seller_protection: %{ "dispute_categories" => ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"], "status" => "ELIGIBLE" }, - "seller_receivable_breakdown" => %{ + seller_receivable_breakdown: %{ "gross_amount" => %{"currency_code" => "EUR", "value" => "10.00"}, "net_amount" => %{"currency_code" => "EUR", "value" => "9.31"}, "paypal_fee" => %{"currency_code" => "EUR", "value" => "0.69"} }, - "status" => "COMPLETED", - "update_time" => "2024-05-10T12:19:16Z" + status: "COMPLETED", + update_time: "2024-05-10T12:19:16Z" } ] } From 1ac62ca653290639644d528c69a9c428ca78e8aa Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Mon, 3 Feb 2025 19:27:36 +0100 Subject: [PATCH 2/4] added payment refund --- lib/paypal/payment.ex | 14 +++++ lib/paypal/payment/refund.ex | 85 +++++++++++++++++++++++++++++++ test/integration_test.exs | 99 ++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 lib/paypal/payment/refund.ex diff --git a/lib/paypal/payment.ex b/lib/paypal/payment.ex index d70e4d5..dc2fbee 100644 --- a/lib/paypal/payment.ex +++ b/lib/paypal/payment.ex @@ -11,6 +11,7 @@ defmodule Paypal.Payment do alias Paypal.Common.Error, as: PaymentError alias Paypal.Payment.Captured alias Paypal.Payment.Info + alias Paypal.Payment.Refund adapter({Tesla.Adapter.Finch, name: Paypal.Finch}) @@ -78,4 +79,17 @@ defmodule Paypal.Payment do error end end + + def refund(id) do + case post("/captures/#{id}/refund", "") do + {:ok, %_{status: code, body: response}} when code in 200..299 -> + {:ok, Refund.cast(response)} + + {:ok, %_{body: response}} -> + {:error, PaymentError.cast(response)} + + {:error, _} = error -> + error + end + end end diff --git a/lib/paypal/payment/refund.ex b/lib/paypal/payment/refund.ex new file mode 100644 index 0000000..ba317b8 --- /dev/null +++ b/lib/paypal/payment/refund.ex @@ -0,0 +1,85 @@ +defmodule Paypal.Payment.Refund do + @moduledoc """ + Payment refund information. The information retrieved from Paypal about the + refund. + + ## Fields + + - `id` - The unique ID for the capture. + - `status` - The status of the capture (e.g. `"COMPLETED"`). + - `status_details` - The details of the capture status. + - `invoice_id` - The API caller-provided external invoice number for this order. + - `custom_id` - The API caller-provided external ID. + - `payer` - An embedded schema representing the payer. + - `create_time` - The date and time when the capture was created (ISO 8601 string). + - `update_time` - The date and time when the capture was last updated (ISO 8601 string). + - `amount` - An embedded schema representing the monetary amount of the capture. + - `acquirer_reference_number` - Reference ID issued for the card transaction. + - `note_to_payer` - The reason for the refund. + - `seller_protection` - An embedded schema containing details about seller protection. + - `seller_payable_breakdown` - An embedded schema that details the seller_payable_breakdown. + - `links` - A list of embedded link objects for further API actions. + """ + + use TypedEctoSchema + import Ecto.Changeset + alias Paypal.Common.CurrencyValue + alias Paypal.Common.Link + alias Paypal.Order.Payer + + @statuses [ + cancelled: "CANCELLED", + failed: "FAILED", + pending: "PENDING", + completed: "COMPLETED" + ] + + @primary_key false + typed_embedded_schema do + field(:id, :string) + field(:status, Ecto.Enum, values: @statuses, embed_as: :dumped) + field(:status_details, :string) + field(:invoice_id, :string) + field(:custom_id, :string) + field(:create_time, :string) + field(:update_time, :string) + field(:acquirer_reference_number, :string) + field(:note_to_payer, :string) + # TODO + field(:seller_protection, :map) + # TODO + field(:seller_payable_breakdown, :map) + + embeds_one(:payer, Payer) + embeds_one(:amount, CurrencyValue) + embeds_many(:links, Link) + end + + @fields ~w[ + status + status_details + id + invoice_id + custom_id + acquirer_reference_number + seller_protection + note_to_payer + seller_payable_breakdown + create_time + update_time + ]a + + @doc false + def changeset(model \\ %__MODULE__{}, params) do + model + |> cast(params, @fields) + |> cast_embed(:amount, required: true) + |> cast_embed(:links) + |> cast_embed(:payer) + end + + @doc false + def cast(params) do + Ecto.embedded_load(__MODULE__, params, :json) + end +end diff --git a/test/integration_test.exs b/test/integration_test.exs index bc304a8..67ce35a 100644 --- a/test/integration_test.exs +++ b/test/integration_test.exs @@ -521,6 +521,105 @@ defmodule Paypal.IntegrationTest do } assert {:ok, payment_captured} == Paypal.Payment.capture("27A385875N551040L") + + Bypass.expect_once( + bypass, + "POST", + "/v2/payments/captures/5MS70068BM212023M/refund", + fn conn -> + response(conn, 201, %{ + "id" => "58K15806CS993444T", + "amount" => %{ + "currency_code" => "USD", + "value" => "89.00" + }, + "seller_payable_breakdown" => %{ + "gross_amount" => %{ + "currency_code" => "USD", + "value" => "89.00" + }, + "paypal_fee" => %{ + "currency_code" => "USD", + "value" => "0.00" + }, + "net_amount" => %{ + "currency_code" => "USD", + "value" => "89.00" + }, + "total_refunded_amount" => %{ + "currency_code" => "USD", + "value" => "100.00" + } + }, + "invoice_id" => "OrderInvoice-10_10_2024_12_58_20_pm", + "status" => "COMPLETED", + "create_time" => "2024-10-14T15:03:29-07:00", + "update_time" => "2024-10-14T15:03:29-07:00", + "links" => [ + %{ + "href" => + "https://api.msmaster.qa.paypal.com/v2/payments/refunds/58K15806CS993444T", + "rel" => "self", + "method" => "GET" + }, + %{ + "href" => + "https://api.msmaster.qa.paypal.com/v2/payments/captures/7TK53561YB803214S", + "rel" => "up", + "method" => "GET" + } + ] + }) + end + ) + + payment_refund = %Paypal.Payment.Refund{ + id: "58K15806CS993444T", + invoice_id: "OrderInvoice-10_10_2024_12_58_20_pm", + custom_id: nil, + links: [ + %Paypal.Common.Link{ + enc_type: nil, + href: "https://api.msmaster.qa.paypal.com/v2/payments/refunds/58K15806CS993444T", + rel: "self", + method: :get + }, + %Paypal.Common.Link{ + enc_type: nil, + href: "https://api.msmaster.qa.paypal.com/v2/payments/captures/7TK53561YB803214S", + rel: "up", + method: :get + } + ], + status: :completed, + status_details: nil, + seller_payable_breakdown: %{ + "gross_amount" => %{ + "currency_code" => "USD", + "value" => "89.00" + }, + "paypal_fee" => %{ + "currency_code" => "USD", + "value" => "0.00" + }, + "net_amount" => %{ + "currency_code" => "USD", + "value" => "89.00" + }, + "total_refunded_amount" => %{ + "currency_code" => "USD", + "value" => "100.00" + } + }, + amount: %Paypal.Common.CurrencyValue{ + currency_code: "USD", + value: Decimal.new("89.00") + }, + create_time: "2024-10-14T15:03:29-07:00", + update_time: "2024-10-14T15:03:29-07:00" + } + + assert {:ok, payment_refund} == Paypal.Payment.refund("5MS70068BM212023M") end test "order authorized and voided", %{bypass: bypass} do From d8657be4ade688c7e298cade22a6881888a9f65a Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Tue, 4 Feb 2025 09:49:45 +0100 Subject: [PATCH 3/4] added chance to send a body with refund --- lib/paypal/payment.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/paypal/payment.ex b/lib/paypal/payment.ex index dc2fbee..ca06348 100644 --- a/lib/paypal/payment.ex +++ b/lib/paypal/payment.ex @@ -80,8 +80,8 @@ defmodule Paypal.Payment do end end - def refund(id) do - case post("/captures/#{id}/refund", "") do + def refund(id, body \\ "") do + case post("/captures/#{id}/refund", body) do {:ok, %_{status: code, body: response}} when code in 200..299 -> {:ok, Refund.cast(response)} From a5704036e4f1f9774a862ac01f3e179793fd251b Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Tue, 4 Feb 2025 16:47:56 +0100 Subject: [PATCH 4/4] added a refund request schema --- lib/paypal/payment.ex | 7 ++-- lib/paypal/payment/refund_request.ex | 49 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 lib/paypal/payment/refund_request.ex diff --git a/lib/paypal/payment.ex b/lib/paypal/payment.ex index ca06348..e7b5795 100644 --- a/lib/paypal/payment.ex +++ b/lib/paypal/payment.ex @@ -12,6 +12,7 @@ defmodule Paypal.Payment do alias Paypal.Payment.Captured alias Paypal.Payment.Info alias Paypal.Payment.Refund + alias Paypal.Payment.RefundRequest adapter({Tesla.Adapter.Finch, name: Paypal.Finch}) @@ -80,8 +81,10 @@ defmodule Paypal.Payment do end end - def refund(id, body \\ "") do - case post("/captures/#{id}/refund", body) do + @spec refund(String.t(), RefundRequest.t() | map()) :: + {:ok, Info.t()} | {:error, PaymentError.t() | String.t()} + def(refund(id, body \\ %{})) do + case post("/captures/#{id}/refund", body |> RefundRequest.cast() |> Jason.encode!()) do {:ok, %_{status: code, body: response}} when code in 200..299 -> {:ok, Refund.cast(response)} diff --git a/lib/paypal/payment/refund_request.ex b/lib/paypal/payment/refund_request.ex new file mode 100644 index 0000000..eb8aa1f --- /dev/null +++ b/lib/paypal/payment/refund_request.ex @@ -0,0 +1,49 @@ +defmodule Paypal.Payment.RefundRequest do + @moduledoc """ + Request object that Refunds a captured payment, by ID. For a full refund, include an empty + request body. For a partial refund, include an `amount` object in + the request body. + + ## Fields + + - `amount` - The currency and amount for a financial transaction, such as a balance or payment due. + - `custom_id` - The API caller-provided external ID. Used to reconcile API caller-initiated transactions with PayPal transactions. + - `invoice_id` - The API caller-provided external invoice ID for this order. + - `note_to_payer` - The reason for the refund. Appears in both the payer's transaction history and the emails that the payer receives. + - `payment_instruction` - Any additional payments instructions during refund payment processing. + """ + + use TypedEctoSchema + import Ecto.Changeset + alias Paypal.Common.CurrencyValue + + @derive Jason.Encoder + @primary_key false + typed_embedded_schema do + embeds_one(:amount, CurrencyValue) + field(:custom_id, :string) + field(:invoice_id, :string) + field(:note_to_payer, :string) + # TODO + field(:payment_instruction, :map) + end + + @fields ~w[ + custom_id + invoice_id + note_to_payer + payment_instruction + ]a + + @doc false + def changeset(model \\ %__MODULE__{}, params) do + model + |> cast(params, @fields) + |> cast_embed(:amount) + end + + @doc false + def cast(params) do + Ecto.embedded_load(__MODULE__, params, :json) + end +end