-
Notifications
You must be signed in to change notification settings - Fork 84
Fetch webhook events from Stripe #577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,79 +5,104 @@ defmodule CodeCorps.StripeService.WebhookProcessing.WebhookProcessor do | |
|
|
||
| alias CodeCorps.StripeEvent | ||
| alias CodeCorps.Repo | ||
| alias CodeCorps.StripeService.WebhookProcessing.{ConnectEventHandler, PlatformEventHandler} | ||
|
|
||
| @api Application.get_env(:code_corps, :stripe) | ||
|
|
||
| @doc """ | ||
| Used to process a Stripe webhook event. | ||
|
|
||
| Receives the event json as the first parameter. | ||
| Since a webhook can be a platform or a connect webhook, | ||
| the function requires the handler module as the second parameter. | ||
| Receives the event JSON as the first parameter. | ||
|
|
||
| Since a webhook can be a platform or a connect webhook, the function requires | ||
| the handler module as the second parameter. | ||
|
|
||
| ## Returns | ||
|
|
||
| * `{:ok, :ignored_by_environment}` if the event was ignored due to environment mismatch | ||
| * `{:ok, :enqueued}` if the event will be handled | ||
| - `{:ok, pid}` if the event will be handled | ||
| - `{:error, :ignored_by_environment}` if the event was ignored due to | ||
| environment mismatch | ||
|
|
||
| ## Note | ||
|
|
||
| Stripe events can have their `livemode` property set to `true` or `false`. | ||
| A livemode event should be handled by the production environment, while all other environments | ||
| handle non-livemode events. | ||
| A livemode `true` event should be handled by the production environment, | ||
| while all other environments handle livemode `false` events. | ||
| """ | ||
| def process_async(%{} = json, handler) do | ||
| case event_matches_environment?(json) do | ||
| true -> do_process_async(json, handler) | ||
| false -> {:ok, :ignored_by_environment} | ||
| def process_async(%{"id" => id, "livemode" => livemode, "user_id" => user_id} = json, handler) do | ||
| case event_matches_environment?(livemode) do | ||
| true -> do_process_async(id, user_id, handler, json) | ||
| false -> {:error, :ignored_by_environment} | ||
| end | ||
| end | ||
|
|
||
| defp do_process_async(json, handler) do | ||
| Task.Supervisor.start_child(:webhook_processor, fn -> do_process(json, handler) end) | ||
| def process_async(%{"id" => id, "livemode" => livemode} = json, handler) do | ||
| case event_matches_environment?(livemode) do | ||
| true -> do_process_async(id, nil, handler, json) | ||
| false -> {:error, :ignored_by_environment} | ||
| end | ||
| end | ||
|
|
||
| defp event_matches_environment?(%{"livemode" => livemode}) do | ||
| case Application.get_env(:code_corps, :stripe_env) do | ||
| :prod -> livemode | ||
| _ -> !livemode | ||
| end | ||
| defp do_process_async(id, user_id, handler, json) do | ||
| Task.Supervisor.start_child(:webhook_processor, fn -> do_process(id, user_id, handler, json) end) | ||
| end | ||
|
|
||
| defp do_process(%{"id" => event_id, "type" => event_type} = json, handler) do | ||
| with {:ok, %StripeEvent{} = event} <- find_or_create_event(event_id, event_type) do | ||
| case handler.handle_event(json) |> Tuple.to_list do | ||
| [:ok, :unhandled_event] -> event |> set_unhandled | ||
| [:ok | _results] -> event |> set_processed | ||
| [:error | _error] -> event |> set_errored | ||
| end | ||
| defp do_process(id, user_id, handler, json) do | ||
| with {:ok, %Stripe.Event{id: api_event_id, type: api_event_type, user_id: api_user_id}} <- retrieve_event_from_api(id, user_id), | ||
| {:ok, endpoint} <- infer_endpoint_from_handler(handler), | ||
| {:ok, %StripeEvent{} = event} <- find_or_create_event(api_event_id, api_event_type, api_user_id, endpoint) | ||
| do | ||
| handle_event(json, event, handler) | ||
| else | ||
| {:error, :already_processing} -> nil | ||
| end | ||
| end | ||
|
|
||
| defp find_or_create_event(id_from_stripe, type) do | ||
| defp event_matches_environment?(livemode) do | ||
| case Application.get_env(:code_corps, :stripe_env) do | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rossta we still want to be able to process events in staging, so we would never handle events with this situation.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gotcha
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried thinking about how to improve this because it reads strange, but think it's isolated enough to not make a difference, and is kind of addressed in the moduledoc. |
||
| :prod -> livemode | ||
| _ -> !livemode | ||
| end | ||
| end | ||
|
|
||
| defp find_or_create_event(id_from_stripe, type, user_id, endpoint) do | ||
| case find_event(id_from_stripe) do | ||
| %StripeEvent{status: "processing"} -> {:error, :already_processing} | ||
| %StripeEvent{} = event -> {:ok, event} | ||
| nil -> create_event(id_from_stripe, type) | ||
| nil -> create_event(id_from_stripe, endpoint, type, user_id) | ||
| end | ||
| end | ||
|
|
||
| defp find_event(id_from_stripe) do | ||
| Repo.get_by(StripeEvent, id_from_stripe: id_from_stripe) | ||
| end | ||
|
|
||
| defp create_event(id_from_stripe, type) do | ||
| %StripeEvent{} |> StripeEvent.create_changeset(%{id_from_stripe: id_from_stripe, type: type}) |> Repo.insert | ||
| defp handle_event(json, event, handler) do | ||
| case handler.handle_event(json) |> Tuple.to_list do | ||
| [:ok, :unhandled_event] -> event |> set_unhandled | ||
| [:ok | _results] -> event |> set_processed | ||
| [:error | _error] -> event |> set_errored | ||
| end | ||
| end | ||
|
|
||
| defp set_processed(%StripeEvent{} = event) do | ||
| event |> StripeEvent.update_changeset(%{status: "processed"}) |> Repo.update | ||
| defp infer_endpoint_from_handler(ConnectEventHandler), do: {:ok, "connect"} | ||
| defp infer_endpoint_from_handler(PlatformEventHandler), do: {:ok, "platform"} | ||
| defp infer_endpoint_from_handler(_), do: {:error, :invalid_handler} | ||
|
|
||
| defp retrieve_event_from_api(id, nil), do: @api.Event.retrieve(id) | ||
| defp retrieve_event_from_api(id, user_id), do: @api.Event.retrieve(id, connect_account: user_id) | ||
|
|
||
| defp create_event(id_from_stripe, endpoint, type, user_id) do | ||
| %StripeEvent{} |> StripeEvent.create_changeset(%{endpoint: endpoint, id_from_stripe: id_from_stripe, type: type, user_id: user_id}) |> Repo.insert | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we follow any conventions on when to split out a pipe to a new line?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We nominally use |
||
| end | ||
|
|
||
| defp set_errored(%StripeEvent{} = event) do | ||
| event |> StripeEvent.update_changeset(%{status: "errored"}) |> Repo.update | ||
| end | ||
|
|
||
| defp set_processed(%StripeEvent{} = event) do | ||
| event |> StripeEvent.update_changeset(%{status: "processed"}) |> Repo.update | ||
| end | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I often struggle with the question of whether or not to introduce another helper method for helper methods like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I wasn't sure about this, hemmed and hawed, but ultimately this is probably okay. Was thinking we could just have done |
||
|
|
||
| defp set_unhandled(%StripeEvent{} = event) do | ||
| event |> StripeEvent.update_changeset(%{status: "unhandled"}) |> Repo.update | ||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| defmodule CodeCorps.StripeTesting.Event do | ||
| def retrieve(id, _opts = [connect_account: _]) do | ||
| {:ok, do_retrieve_connect(id)} | ||
| end | ||
| def retrieve(id) do | ||
| {:ok, do_retrieve(id)} | ||
| end | ||
|
|
||
| defp do_retrieve(_) do | ||
| {:ok, created} = DateTime.from_unix(1479472835) | ||
|
|
||
| %Stripe.Event{ | ||
| api_version: "2016-07-06", | ||
| created: created, | ||
| id: "evt_123", | ||
| livemode: false, | ||
| object: "event", | ||
| pending_webhooks: 1, | ||
| request: nil, | ||
| type: "any.event" | ||
| } | ||
| end | ||
|
|
||
| defp do_retrieve_connect(_) do | ||
| {:ok, created} = DateTime.from_unix(1479472835) | ||
|
|
||
| %Stripe.Event{ | ||
| api_version: "2016-07-06", | ||
| created: created, | ||
| id: "evt_123", | ||
| livemode: false, | ||
| object: "event", | ||
| pending_webhooks: 1, | ||
| request: nil, | ||
| type: "any.event", | ||
| user_id: "acct_123" | ||
| } | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| defmodule CodeCorps.Repo.Migrations.AddUserIdAndEndpointToStripeEvents do | ||
| use Ecto.Migration | ||
|
|
||
| def change do | ||
| alter table(:stripe_events) do | ||
| add :endpoint, :string, null: false | ||
| add :user_id, :string | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like you can consolidate this def with the one on line 32 unless I'm missing something:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rossta the one on line 32 is doing pattern matching against the user_id key, so on platform events the signature will be off.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yah, but isn't it just passing the
user_idvalue into the same place where it would benilin the other example? So I thoughtjson["user_id"]as the second param to thetruecase would handle both implementations but I could be wrong.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rossta yeah, I'm not sure how I feel about it. On the one hand I like the explicitness of saying that there are two different ways we handle this, but I suppose it's the same outcome.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did miss your code example somehow earlier so just read "consolidate" somehow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I get what you mean about being explicit. In that case, I'm usually inclined to extract an intention-revealing variable or method to help highlight the difference. That may not be the best way to handle it as I'm still learning my way around the conventions in the codebase.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's possible that this interface should be redesigned to have two public fn's, one for processing
platformand one forconnectevents, and then the controller does some pattern matching againstuser_id. I believe that everyconnectevent should have auser_id, but I could be wrong.