Skip to main content

src/livery_stripe_webhook_handler.erl

-module(livery_stripe_webhook_handler).
-moduledoc """
A mountable livery handler for the Stripe webhook endpoint.

Add it to a router with `routes/0,1`:

```erlang
livery_router:compile(livery_stripe_webhook_handler:routes()).
```

`handle/1` reads the raw body and `Stripe-Signature` header, verifies the
event with `livery_stripe_webhook`, and dispatches it to the configured
callback (the `webhook_callback` config key). It always answers JSON: 200
when accepted, 400 on a bad payload or signature.

The callback may be a `fun/1` (`Event`), a `fun/2` (`Type, Event`), a
module implementing `handle_event/2`, or a `{Module, Function}` pair.
Persistence (updating a user's subscription, etc.) belongs in the
callback; this module stays storage-agnostic.
""".

-export([handle/1, handle/2, routes/0, routes/1]).

-callback handle_event(Type :: binary(), Event :: map()) -> ok | {error, term()}.

-doc "livery route handler. Reads secret and callback from app config.".
-spec handle(livery_req:req()) -> livery_resp:resp().
handle(Req) ->
    handle(Req, handler_opts()).

-doc "Like `handle/1` but with explicit `#{secret, callback}` opts.".
-spec handle(livery_req:req(), map()) -> livery_resp:resp().
handle(Req, Opts) ->
    Secret = maps:get(secret, Opts, undefined),
    Callback = maps:get(callback, Opts, undefined),
    Raw = read_body(Req),
    Sig = livery_req:header(<<"stripe-signature">>, Req),
    case livery_stripe_webhook:construct_event(Raw, Sig, Secret) of
        {ok, Event} ->
            _ = dispatch(Callback, Event),
            livery_resp:json(200, <<"{\"received\":true}">>);
        {error, invalid_payload} ->
            livery_resp:json(400, <<"{\"error\":\"invalid payload\"}">>);
        {error, _} ->
            livery_resp:json(400, <<"{\"error\":\"invalid signature\"}">>)
    end.

-doc "Routes for the default path `/stripe/webhook`.".
-spec routes() -> [tuple()].
routes() ->
    routes(<<"/stripe/webhook">>).

-spec routes(binary()) -> [tuple()].
routes(Path) ->
    [{<<"POST">>, Path, {?MODULE, handle}}].

%%====================================================================
%% Internals
%%====================================================================

dispatch(undefined, _Event) ->
    ok;
dispatch(Fun, Event) when is_function(Fun, 1) ->
    Fun(Event);
dispatch(Fun, Event) when is_function(Fun, 2) ->
    Fun(event_type(Event), Event);
dispatch({Mod, Fun}, Event) ->
    Mod:Fun(event_type(Event), Event);
dispatch(Mod, Event) when is_atom(Mod) ->
    Mod:handle_event(event_type(Event), Event).

event_type(Event) ->
    maps:get(<<"type">>, Event, <<>>).

handler_opts() ->
    Cfg = livery_stripe:config(),
    #{
        secret => maps:get(webhook_secret, Cfg, undefined),
        callback => maps:get(webhook_callback, Cfg, undefined)
    }.

read_body(Req) ->
    case livery_req:body(Req) of
        empty ->
            <<>>;
        {buffered, IoData} ->
            iolist_to_binary(IoData);
        {stream, Reader} ->
            case livery_body:read_all(Reader) of
                {ok, Bin, _Reader} -> Bin;
                {error, _Reason, _Reader} -> <<>>
            end
    end.