HG-3: Add stubbed invoice machine and all the wiring (#2)

* HG-3: Add stubbed invoice machine and all the wiring

* HG-3: Bump damsel to a proper revision

* HG-3: Get rid of precompile hook to make submodules work

* HG-3: Add missing elvis config

* HG-3: Switch to proto fork temporarily

* HG-3: Merge dispatcher activities w/ machine behaviour

* HG-4: Switch to new proto fork temporarily

* HG-3: Avoid `submodule init` on every make invocation

* HG-3: Allow to pass datetime in both native and iso8601 format

* HG-4: Switch to new proto fork temporarily

* HG-4: Adapt to new protocol + internal & external events

* HG-3: Switch to proto fork already

* HG-21: Add containerization maketargets

* HG-4: Fix interfaces and add missing activities

* HG-4: Switch to new proto fork temporarily

* HG-6: Fix ruble currency code

* HG-4: Fix interface issues

* HG-4: Add default config

* HG-4: Switch to new proto fork temporarily

* HG-4: Start filling provider proxy interaction in

* Publish TODOs

* HG-4: Stub a provider proxy w/ settings from app env

* HG-4: Fix copypasta

* HG-4: Add dummy provider proxy, to be moved into testsuite

* HG-4: Switch to new proto fork temporarily

* HG-21: Remove nonfunctional target dependencies

* HG-4: Rename hg_action to make its objective clearer

* HG-4: Simplify interface address manipulation

* HG-4: Compile proxy related thrift files

* HG-4: Switch to new proto fork temporarily

* HG-4: Update TODOs

* HG-4: Isolate service specs and put them to the proto lib

* HG-4: Move dummy provider into the test dir

* HG-4: Fix getting events with respect to proto update

* damsel@24a247b

* HG-4: Introduce hg client + add preliminary test suite

* HG-4: Merge woody handler with invoice module

* HG-4: Fuse processor handler with machine

* HG-4: Harden the build + fix typing errors alongside

* HG-4: Add happy payment testcase + stateful client

* HG-4: Update gitignore rules with respect to wercker beta

* HG-4: Stash a couple of items into TODO

* HG-4: Make trivial behaviour for test provider(s)

* HG-4: Update elvis rules + lint tests' code

* HG-4: Make UserInfo a part of the client + simplify test code with macros

* HG-4: Cleanup dirty proxy state after testcases

* HG-4: Rename test_provider to a wider test_proxy

* HG-4: Explicitly mention requirement on manually started mgun
This commit is contained in:
Andrew Mayorov 2016-06-15 19:10:22 +03:00 committed by GitHub
parent 20a01ee95c
commit ceb2013c89
35 changed files with 1975 additions and 66 deletions

1
.gitignore vendored
View File

@ -13,3 +13,4 @@ erl_crash.dump
/_projects/
/_steps/
/_temp/
/.wercker/

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "apps/hg_proto/damsel"]
path = apps/hg_proto/damsel
url = git@github.com:keynslug/damsel.git

View File

@ -1,26 +1,36 @@
REBAR := $(shell which rebar3 2>/dev/null || which ./rebar3)
RELNAME = hellgate
SUBMODULES = apps/hg_proto/damsel
SUBTARGETS = $(patsubst %,%/.git,$(SUBMODULES))
.PHONY: all compile devrel start test clean distclean dialyze
.PHONY: all submodules compile devrel start test clean distclean dialyze release containerize
all: compile
compile:
$(REBAR) compile
rebar-update:
$(REBAR) update
devrel:
$(SUBTARGETS): %/.git: %
git submodule update --init $<
touch $@
submodules: $(SUBTARGETS)
compile: submodules
$(REBAR) compile
devrel: submodules
$(REBAR) release
start:
start: submodules
$(REBAR) run
test:
test: submodules
$(REBAR) ct
xref:
lint: compile
elvis rock
xref: submodules
$(REBAR) xref
clean:
@ -32,3 +42,16 @@ distclean:
dialyze:
$(REBAR) dialyzer
DOCKER := $(shell which docker 2>/dev/null)
PACKER := $(shell which packer 2>/dev/null)
BASE_DIR := $(shell pwd)
release: ~/.docker/config.json distclean
$(DOCKER) run --rm -v $(BASE_DIR):$(BASE_DIR) --workdir $(BASE_DIR) rbkmoney/build rebar3 as prod release
containerize: release ./packer.json
$(PACKER) build packer.json
~/.docker/config.json:
test -f ~/.docker/config.json || (echo "Please run: docker login" ; exit 1)

15
TODO.md Normal file
View File

@ -0,0 +1,15 @@
# Invoicing
* Handle error properly while calling `Automaton`, perfect to pass them untouched with the help of latest `woody` release.
* Better and easier to compehend flow control in machines.
* More familiar flow control handling of machines, e.g. catching and wrapping thrown exceptions.
* Explicit stage denotion in the invoice machine?
* __Submachine abstraction and payment submachine implementation__.
* __Properly pass woody contexts around__.
* __Invoice access control__.
# Tests
* Fix excess `localhost` definitions (as soon as service discovery strategy will be finalized, hopefully).
* __Add generic albeit more complex test suite which covers as many state transitions with expected effects as possible__.
* Employ macros to minimize pattern matching boilerplate.

View File

@ -0,0 +1,3 @@
{erl_opts, [
{parse_transform, lager_transform}
]}.

View File

@ -1,11 +1,18 @@
{application, hellgate, [
{description, "A service that does something"},
{description,
"Umbrella project for state processors implementing payment processing activities"
},
{vsn, "1"},
{registered, []},
{mod, {hellgate, []}},
{applications, [
kernel,
stdlib
stdlib,
lager,
genlib,
cowboy,
woody,
hg_proto
]},
{env, []},
{modules, []},

View File

@ -7,14 +7,14 @@
%% API
-export([start/0]).
-export([stop /0]).
-export([stop/0]).
%% Supervisor callbacks
-export([init/1]).
%% Application callbacks
-export([start/2]).
-export([stop /1]).
-export([stop/1]).
%%
%% API
@ -29,17 +29,38 @@ start() ->
stop() ->
application:stop(?MODULE).
%%
%% Supervisor callbacks
%%
-spec init([]) ->
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
{ok, {
{one_for_all, 0, 1}, []
#{strategy => one_for_all, intensity => 6, period => 30},
[get_api_child_spec()]
}}.
%%
get_api_child_spec() ->
woody_server:child_spec(
?MODULE,
#{
ip => hg_utils:get_hostname_ip(genlib_app:env(?MODULE, host, "localhost")),
port => genlib_app:env(?MODULE, port, 8800),
net_opts => [],
event_handler => hg_woody_event_handler,
handlers => [
construct_service_handler(invoicing, hg_invoice, []),
construct_service_handler(processor, hg_machine, [])
]
}
).
construct_service_handler(Name, Module, Opts) ->
{Name, Path, Service} = hg_proto:get_service_spec(Name),
{Path, {Service, Module, Opts}}.
%% Application callbacks
%%
-spec start(normal, any()) ->
{ok, pid()} | {error, any()}.
start(_StartType, _StartArgs) ->

View File

@ -0,0 +1,69 @@
-module(hg_domain).
-include_lib("hg_proto/include/hg_domain_thrift.hrl").
%%
-export([head/0]).
-export([all/1]).
-export([get/2]).
%%
-type revision() :: pos_integer().
-type ref() :: _.
-type data() :: _.
-spec head() -> revision().
head() ->
42.
-spec all(revision()) -> hg_domain_thrift:'Domain'().
all(_Revision) ->
get_fixture().
-spec get(revision(), ref()) -> data().
get(Revision, Ref) ->
% FIXME: the dirtiest hack you'll ever see
Name = type_to_name(Ref),
case maps:get({Name, Ref}, all(Revision), undefined) of
{Name, {_, Ref, Data}} ->
Data;
undefined ->
undefined
end.
type_to_name(#'CurrencyRef'{}) ->
currency;
type_to_name(#'ProxyRef'{}) ->
proxy.
%%
-define(
object(ObjectName, Ref, Data),
{type_to_name(Ref), Ref} => {type_to_name(Ref), {ObjectName, Ref, Data}}
).
get_fixture() ->
#{
?object('CurrencyObject',
#'CurrencyRef'{symbolic_code = <<"RUB">>},
#'Currency'{
name = <<"Russian rubles">>,
numeric_code = 643,
symbolic_code = <<"RUB">>,
exponent = 2
}
),
?object('ProxyObject',
#'ProxyRef'{id = 1},
#'Proxy'{
type = provider,
url = genlib_app:env(hellgate, provider_proxy_url),
options = genlib_app:env(hellgate, provider_proxy_options, #{})
}
)
}.

View File

@ -0,0 +1,13 @@
-ifndef(__hg_duration__).
-define(__hg_duration__, 42).
-define(seconds(V) , (V)).
-define(minutes(V) , (?seconds(V) * 60)).
-define(hours(V) , (?minutes(V) * 60)).
-define(days(V) , (?hours(V) * 24)).
-define(MINUTE , ?minutes(1)).
-define(HOUR , ?hours(1)).
-define(DAY , ?day(1)).
-endif.

View File

@ -0,0 +1,411 @@
-module(hg_invoice).
-include_lib("hg_proto/include/hg_payment_processing_thrift.hrl").
%% Woody handler
-behaviour(woody_server_thrift_handler).
-export([handle_function/4]).
-export([handle_error/4]).
%% Machine callbacks
-behaviour(hg_machine).
-export([init/2]).
-export([process_signal/2]).
-export([process_call/2]).
%%
-spec handle_function(woody_t:func(), woody_server_thrift_handler:args(), woody_client:context(), []) ->
{ok, term()} | no_return().
handle_function('Create', {UserInfo, InvoiceParams}, Context, _Opts) ->
InvoiceID = hg_machine:start(?MODULE, {InvoiceParams, UserInfo}, opts(Context)),
{ok, InvoiceID};
handle_function('Get', {UserInfo, InvoiceID}, Context, _Opts) ->
InvoiceState = get_invoice_state(get_state(UserInfo, InvoiceID, opts(Context))),
{ok, InvoiceState};
handle_function('GetEvents', {UserInfo, InvoiceID, Range}, Context, _Opts) ->
#'EventRange'{'after' = AfterID, limit = Limit} = Range,
History = get_history(UserInfo, InvoiceID, opts(Context)),
{ok, map_events(select_range(AfterID, Limit, map_history(History)))};
handle_function('StartPayment', {UserInfo, InvoiceID, PaymentParams}, Context, _Opts) ->
Call = {start_payment, PaymentParams, UserInfo},
PaymentID = hg_machine:call(?MODULE, InvoiceID, Call, opts(Context)),
{ok, PaymentID};
handle_function('GetPayment', {UserInfo, PaymentID}, Context, _Opts) ->
St = get_state(UserInfo, deduce_invoice_id(PaymentID), opts(Context)),
case get_payment(PaymentID, St) of
Payment = #'InvoicePayment'{} ->
{ok, Payment};
false ->
throw(payment_not_found())
end;
handle_function('Fulfill', {UserInfo, InvoiceID, Reason}, Context, _Opts) ->
Result = hg_machine:call(?MODULE, InvoiceID, {fulfill, Reason, UserInfo}, opts(Context)),
{ok, Result};
handle_function('Void', {UserInfo, InvoiceID, Reason}, Context, _Opts) ->
Result = hg_machine:call(?MODULE, InvoiceID, {void, Reason, UserInfo}, opts(Context)),
{ok, Result}.
opts(Context) ->
#{context => Context}.
-spec handle_error(woody_t:func(), term(), woody_client:context(), []) ->
_.
handle_error(_Function, _Reason, _Context, _Opts) ->
ok.
%%
get_history(_UserInfo, InvoiceID, Opts) ->
hg_machine:get_history(?MODULE, InvoiceID, Opts).
get_state(UserInfo, InvoiceID, Opts) ->
collapse_history(get_history(UserInfo, InvoiceID, Opts)).
map_events(Evs) ->
[construct_external_event(ID, Ev) || {ID, Ev} <- Evs].
construct_external_event(ID, Ev) ->
#'Event'{id = ID, ev = wrap_external_event(Ev)}.
wrap_external_event(Ev = #'InvoiceStatusChanged'{}) ->
{invoice_status_changed, Ev};
wrap_external_event(Ev = #'InvoicePaymentStatusChanged'{}) ->
{invoice_payment_status_changed, Ev}.
%%
-type invoice() :: hg_domain_thrift:'Invoice'().
-type invoice_id() :: hg_domain_thrift:'InvoiceID'().
-type user_info() :: hg_payment_processing_thrift:'UserInfo'().
-type invoice_params() :: hg_payment_processing_thrift:'InvoiceParams'().
-type payment() :: hg_domain_thrift:'InvoicePayment'().
-type payment_params() :: hg_payment_processing_thrift:'InvoicePaymentParams'().
-type invoice_status() :: hg_domain_thrift:'InvoiceStatus'().
-type payment_id() :: hg_domain_thrift:'InvoicePaymentID'().
-type payment_st() :: hg_invoice_payment:st().
-type payment_trx() :: hg_domain_thrift:'TransactionInfo'().
-type detail() :: binary().
-type error() :: hg_domain_thrift:'OperationError'().
-type stage() ::
idling |
{processing_payment, payment_id(), payment_st()}.
-record(st, {
invoice :: invoice(),
payments = [] :: [payment()],
stage = idling :: stage()
}).
-type st() :: #st{}.
-type ev() ::
{stage_changed, stage()} |
{invoice_created, invoice()} |
{invoice_status_changed, invoice_status(), detail()} |
{payment_created, payment()} |
{payment_state_changed, payment_id(), payment_st()} |
{payment_bound, payment_id(), payment_trx() | undefined} |
{payment_succeeded, payment_id()} |
{payment_failed, payment_id(), error()}.
-spec init(invoice_id(), {invoice_params(), user_info()}) ->
{ok, hg_machine:result([ev()])}.
init(ID, {InvoiceParams, _UserInfo}) ->
Invoice = create_invoice(ID, InvoiceParams),
Event = {invoice_created, Invoice},
ok(Event, set_invoice_timer(Invoice)).
-spec process_signal(hg_machine:signal(), hg_machine:history(ev())) ->
{ok, hg_machine:result([ev()])}.
process_signal(timeout, History) ->
St = #st{invoice = Invoice, stage = Stage} = collapse_history(History),
Status = get_invoice_status(Invoice),
case Stage of
{processing_payment, PaymentID, PaymentState} ->
% there's a payment pending
process_payment(PaymentID, PaymentState, St);
idling when Status == unpaid ->
% invoice is expired
process_expiration(St);
_ ->
ok()
end;
process_signal({repair, _}, History) ->
#st{invoice = Invoice} = collapse_history(History),
ok([], set_invoice_timer(Invoice)).
process_expiration(#st{invoice = Invoice}) ->
{ok, Event} = cancel_invoice(overdue, Invoice),
ok(Event).
process_payment(PaymentID, PaymentState0, St = #st{invoice = Invoice}) ->
% FIXME: code looks shitty, destined to be in payment submachine
Payment = get_payment(PaymentID, St),
case hg_invoice_payment:process(Payment, Invoice, PaymentState0) of
% TODO: check proxy contracts
% binding different trx ids is not allowed
% empty action is questionable to allow
{ok, Trx} ->
% payment finished successfully
Events = [{payment_succeeded, PaymentID}, {invoice_status_changed, paid, <<>>}],
ok(construct_payment_events(PaymentID, Trx, Events));
{{error, Error = #'OperationError'{}}, Trx} ->
% payment finished with error
Event = {payment_failed, PaymentID, Error},
ok(construct_payment_events(PaymentID, Trx, Event));
{{next, Action, PaymentState}, Trx} ->
% payment progressing yet
Event = {payment_state_changed, PaymentID, PaymentState},
ok(construct_payment_events(PaymentID, Trx, Event), Action)
end.
construct_payment_events(PaymentID, Trx = #'TransactionInfo'{}, Events) ->
[{payment_bound, PaymentID, Trx} | wrap_event_list(Events)];
construct_payment_events(_PaymentID, undefined, Events) ->
Events.
-type call() ::
{start_payment, payment_params(), user_info()} |
{fulfill, binary(), user_info()} |
{void, binary(), user_info()}.
-type response() ::
ok | {ok, term()} | {exception, term()}.
-spec process_call(call(), hg_machine:history(ev())) ->
{ok, response(), hg_machine:result([ev()])}.
process_call({start_payment, PaymentParams, _UserInfo}, History) ->
#st{invoice = Invoice, stage = Stage} = collapse_history(History),
Status = get_invoice_status(Invoice),
case Stage of
idling when Status == unpaid ->
Payment = create_payment(PaymentParams, Invoice),
PaymentID = get_payment_id(Payment),
Events = [
{payment_created, Payment},
{payment_state_changed, PaymentID, undefined}
],
respond({ok, PaymentID}, Events, hg_machine_action:instant());
{processing_payment, PaymentID, _} ->
raise(payment_pending(PaymentID));
_ ->
raise(invalid_invoice_status(Invoice))
end;
process_call({fulfill, Reason, _UserInfo}, History) ->
#st{invoice = Invoice} = collapse_history(History),
case fulfill_invoice(Reason, Invoice) of
{ok, Event} ->
respond(ok, Event, set_invoice_timer(Invoice));
{error, Exception} ->
raise(Exception, set_invoice_timer(Invoice))
end;
process_call({void, Reason, _UserInfo}, History) ->
#st{invoice = Invoice} = collapse_history(History),
case cancel_invoice({void, Reason}, Invoice) of
{ok, Event} ->
respond(ok, Event, set_invoice_timer(Invoice));
{error, Exception} ->
raise(Exception, set_invoice_timer(Invoice))
end.
set_invoice_timer(#'Invoice'{status = unpaid, due = Due}) when Due /= undefined ->
Ts = genlib_time:daytime_to_unixtime(genlib_format:parse_datetime_iso8601(Due)),
hg_machine_action:set_timeout(max(Ts - genlib_time:unow(), 0));
set_invoice_timer(_Invoice) ->
hg_machine_action:new().
ok() ->
ok([]).
ok(Event) ->
ok(Event, hg_machine_action:new()).
ok(Event, Action) ->
{ok, {wrap_event_list(Event), Action}}.
respond(Response, Event, Action) ->
{ok, Response, {wrap_event_list(Event), Action}}.
raise(Exception) ->
raise(Exception, hg_machine_action:new()).
raise(Exception, Action) ->
{ok, {exception, Exception}, {[], Action}}.
wrap_event_list(Event) when is_tuple(Event) ->
[Event];
wrap_event_list(Events) when is_list(Events) ->
Events.
%%
create_invoice(ID, V = #'InvoiceParams'{}) ->
Revision = hg_domain:head(),
#'Invoice'{
id = ID,
created_at = get_datetime_utc(),
status = unpaid,
domain_revision = Revision,
due = V#'InvoiceParams'.due,
product = V#'InvoiceParams'.product,
description = V#'InvoiceParams'.description,
context = V#'InvoiceParams'.context,
cost = #'Funds'{
amount = V#'InvoiceParams'.amount,
currency = hg_domain:get(Revision, V#'InvoiceParams'.currency)
}
}.
create_payment(V = #'InvoicePaymentParams'{}, Invoice) ->
#'InvoicePayment'{
id = create_payment_id(Invoice),
created_at = get_datetime_utc(),
status = pending,
payer = V#'InvoicePaymentParams'.payer,
payment_tool = V#'InvoicePaymentParams'.payment_tool,
session = V#'InvoicePaymentParams'.session
}.
create_payment_id(Invoice = #'Invoice'{}) ->
create_payment_id(get_invoice_id(Invoice));
create_payment_id(InvoiceID) ->
<<InvoiceID/binary, ":", "0">>.
deduce_invoice_id(PaymentID) ->
case binary:split(PaymentID, <<":">>) of
[InvoiceID, _] ->
InvoiceID;
_ ->
<<>>
end.
get_invoice_id(#'Invoice'{id = ID}) ->
ID.
get_invoice_status(#'Invoice'{status = Status}) ->
Status.
get_payment_id(#'InvoicePayment'{id = ID}) ->
ID.
cancel_invoice(Reason, #'Invoice'{status = unpaid}) ->
{ok, {invoice_status_changed, cancelled, format_reason(Reason)}};
cancel_invoice(_Reason, Invoice) ->
{error, invalid_invoice_status(Invoice)}.
fulfill_invoice(Reason, #'Invoice'{status = paid}) ->
{ok, {invoice_status_changed, fulfilled, format_reason(Reason)}};
fulfill_invoice(_Reason, Invoice) ->
{error, invalid_invoice_status(Invoice)}.
invalid_invoice_status(Invoice) ->
#'InvalidInvoiceStatus'{status = get_invoice_status(Invoice)}.
payment_not_found() ->
#'InvoicePaymentNotFound'{}.
payment_pending(PaymentID) ->
#'InvoicePaymentPending'{id = PaymentID}.
%%
-spec collapse_history([ev()]) -> st().
collapse_history(History) ->
lists:foldl(fun ({_ID, Ev}, St) -> merge_history(Ev, St) end, #st{}, History).
merge_history(Events, St) when is_list(Events) ->
lists:foldl(fun merge_history/2, St, Events);
merge_history({invoice_created, Invoice}, St) ->
St#st{invoice = Invoice};
merge_history({invoice_status_changed, Status, Details}, St = #st{invoice = I}) ->
St#st{invoice = I#'Invoice'{status = Status, details = Details}};
merge_history({payment_created, Payment}, St) ->
set_payment(Payment, St);
merge_history({payment_state_changed, PaymentID, PaymentState}, St) ->
set_stage({processing_payment, PaymentID, PaymentState}, St);
merge_history({payment_bound, PaymentID, Trx}, St) ->
Payment = get_payment(PaymentID, St),
set_payment(Payment#'InvoicePayment'{trx = Trx}, St);
merge_history({payment_succeeded, PaymentID}, St) ->
Payment = get_payment(PaymentID, St),
set_payment(Payment#'InvoicePayment'{status = succeeded}, set_stage(idling, St));
merge_history({payment_failed, PaymentID, Error}, St) ->
Payment = get_payment(PaymentID, St),
set_payment(Payment#'InvoicePayment'{status = failed, err = Error}, St).
set_stage(Stage, St) ->
St#st{stage = Stage}.
get_payment(PaymentID, St) ->
lists:keyfind(PaymentID, #'InvoicePayment'.id, St#st.payments).
set_payment(Payment, St) ->
St#st{payments = lists:keystore(get_payment_id(Payment), #'InvoicePayment'.id, St#st.payments, Payment)}.
get_invoice_state(#st{invoice = Invoice, payments = Payments}) ->
#'InvoiceState'{invoice = Invoice, payments = Payments}.
%%
map_history(History) ->
lists:reverse(element(2, lists:foldl(
fun ({ID, Evs}, {St, Acc}) -> map_history([{ID, Ev} || Ev <- Evs], St, Acc) end,
{#st{}, []},
History
))).
map_history(Evs, St, Acc) when is_list(Evs) ->
lists:foldl(fun ({ID, Ev}, {St0, Acc0}) -> map_history(ID, Ev, St0, Acc0) end, {St, Acc}, Evs).
map_history(ID, Ev, St, Acc) ->
St1 = merge_history(Ev, St),
{St1, [{ID, Ev1} || Ev1 <- map_history(Ev, St1)] ++ Acc}.
map_history({invoice_created, _}, #st{invoice = Invoice}) ->
[#'InvoiceStatusChanged'{invoice = Invoice}];
map_history({invoice_status_changed, _, _}, #st{invoice = Invoice}) ->
[#'InvoiceStatusChanged'{invoice = Invoice}];
map_history({payment_created, Payment}, _St) ->
[#'InvoicePaymentStatusChanged'{payment = Payment}];
map_history({payment_succeeded, PaymentID}, St) ->
[#'InvoicePaymentStatusChanged'{payment = get_payment(PaymentID, St)}];
map_history({payment_failed, PaymentID, _}, St) ->
[#'InvoicePaymentStatusChanged'{payment = get_payment(PaymentID, St)}];
map_history(_Event, _St) ->
[].
select_range(undefined, Limit, History) ->
select_range(Limit, History);
select_range(AfterID, Limit, History) ->
select_range(Limit, lists:dropwhile(fun ({ID, _}) -> ID =< AfterID end, History)).
select_range(Limit, History) ->
lists:sublist(History, Limit).
%%
%% TODO: fix this dirty hack
format_reason({Pre, V}) ->
genlib:format("~s: ~s", [Pre, genlib:to_binary(V)]);
format_reason(V) ->
genlib:to_binary(V).
get_datetime_utc() ->
genlib_format:format_datetime_iso8601(calendar:universal_time()).

View File

@ -0,0 +1,135 @@
-module(hg_invoice_payment).
-include_lib("hg_proto/include/hg_domain_thrift.hrl").
-include_lib("hg_proto/include/hg_proxy_provider_thrift.hrl").
%% API
-export([process/3]).
%% Machine callbacks
% -behaviour(hg_machine).
% -export([init/2]).
% -export([process_signal/2]).
% -export([process_call/2]).
%%
-record(st, {
stage :: process_payment | capture_payment,
proxy_ref :: hg_domain_thrift:'ProxyRef'(),
proxy_state :: binary() | undefined,
context :: woody_client:context()
}).
-opaque st() :: #st{}.
-export_type([st/0]).
-type invoice() :: hg_domain_thrift:'Invoice'().
-type payment() :: hg_domain_thrift:'InvoicePayment'().
-type payment_trx() :: hg_domain_thrift:'TransactionInfo'() | undefined.
-type error() :: hg_domain_thrift:'OperationError'().
-spec process(payment(), invoice(), st() | undefined) ->
{ok, payment_trx()} |
{{error, error()}, payment_trx()} |
{{next, hg_machine_action:t(), st()}, payment_trx()}.
process(Payment, Invoice, undefined) ->
process(Payment, Invoice, construct_state(Payment, Invoice));
process(Payment, Invoice, St = #st{}) ->
Proxy = get_proxy(Invoice, St),
PaymentInfo = construct_payment_info(Payment, Invoice, Proxy, St),
process_(Proxy, PaymentInfo, St).
process_(Proxy, PaymentInfo, St = #st{stage = process_payment, context = Context}) ->
% FIXME: dirty simulation of one-phase payment through the two-phase interaction
case handle_process_result(process_payment(Proxy, PaymentInfo, Context), St) of
{ok, Trx} ->
NextSt = St#st{stage = capture_payment, proxy_state = undefined},
{{next, hg_machine_action:instant(), NextSt}, Trx};
Result ->
Result
end;
process_(Proxy, PaymentInfo, St = #st{stage = capture_payment, context = Context}) ->
handle_process_result(capture_payment(Proxy, PaymentInfo, Context), St).
handle_process_result(#'ProcessResult'{intent = {_, Intent}, trx = Trx, next_state = ProxyStateNext}, St) ->
handle_process_result(Intent, Trx, St#st{proxy_state = ProxyStateNext}).
handle_process_result(#'FinishIntent'{status = {ok, _}}, Trx, _) ->
{ok, Trx};
handle_process_result(#'FinishIntent'{status = {failure, Error}}, Trx, _) ->
{{error, map_error(Error)}, Trx};
handle_process_result(#'SleepIntent'{timer = Timer}, Trx, StNext) ->
{{next, hg_machine_action:set_timer(Timer), StNext}, Trx}.
get_proxy(#'Invoice'{domain_revision = Revision}, #st{proxy_ref = Ref}) ->
hg_domain:get(Revision, Ref).
construct_payment_info(Payment, Invoice, Proxy, #st{proxy_state = ProxyState}) ->
#'PaymentInfo'{
invoice = Invoice,
payment = Payment,
options = Proxy#'Proxy'.options,
state = ProxyState
}.
map_error(#'Error'{code = Code, description = Description}) ->
#'OperationError'{code = Code, description = Description}.
%%
construct_state(Payment, Invoice) ->
#st{
stage = process_payment,
proxy_ref = select_proxy(Payment, Invoice),
context = construct_context()
}.
construct_context() ->
ReqID = genlib_format:format_int_base(genlib_time:ticks(), 62),
woody_client:new_context(ReqID, hg_woody_event_handler).
select_proxy(_, _) ->
% FIXME: turbo routing
#'ProxyRef'{id = 1}.
%% Proxy provider client
-define(SERVICE, {hg_proxy_provider_thrift, 'ProviderProxy'}).
-type process_payment_result() :: hg_proxy_provider_thrift:'ProcessResult'().
-spec process_payment(
hg_domain_thrift:'Proxy'(),
hg_proxy_provider_thrift:'PaymentInfo'(),
woody_client:context()
) ->
process_payment_result().
process_payment(Proxy, PaymentInfo, Context) ->
call(Context, Proxy, {?SERVICE, 'ProcessPayment', [PaymentInfo]}).
-spec capture_payment(
hg_domain_thrift:'Proxy'(),
hg_proxy_provider_thrift:'PaymentInfo'(),
woody_client:context()
) ->
process_payment_result().
capture_payment(Proxy, PaymentInfo, Context) ->
call(Context, Proxy, {?SERVICE, 'CapturePayment', [PaymentInfo]}).
call(Context, Proxy, Call) ->
Endpoint = get_call_options(Proxy),
try woody_client:call(Context, Call, Endpoint) of
{{ok, Result = #'ProcessResult'{}}, _} ->
Result
catch
% TODO: support retry strategies
{#'TryLater'{e = _Error}, _} ->
#'ProcessResult'{intent = {sleep, #'SleepIntent'{timer = {timeout, 10}}}}
end.
get_call_options(#'Proxy'{url = Url}) ->
#{url => Url}.

View File

@ -0,0 +1,225 @@
-module(hg_machine).
-type id() :: binary().
-type args() :: _.
-type event() :: _.
-type history(Event) :: [Event].
-type history() :: history(event()).
-type result(Event) :: {Event, hg_machine_action:t()}.
-type result() :: result(event()).
-callback init(id(), args()) ->
{ok, result()}.
-type signal() ::
timeout | {repair, args()}.
-callback process_signal(signal(), history()) ->
{ok, result()}.
-type call() :: _.
-type response() :: ok | {ok, term()} | {exception, term()}.
-callback process_call(call(), history()) ->
{ok, response(), result()}.
-export_type([id/0]).
-export_type([event/0]).
-export_type([signal/0]).
-export_type([history/0]).
-export_type([history/1]).
-export_type([result/0]).
-export_type([result/1]).
-export([start/3]).
-export([call/4]).
-export([get_history/3]).
-export([dispatch_signal/3]).
-export([dispatch_call/3]).
%% Woody handler
-behaviour(woody_server_thrift_handler).
-export([handle_function/4]).
-export([handle_error/4]).
%%
-include_lib("hg_proto/include/hg_state_processing_thrift.hrl").
-type opts() :: #{
context => woody_client:context()
}.
%%
-spec start(module(), term(), opts()) -> id().
start(Module, Args, #{context := Context}) ->
{{ok, Response}, _} = call_automaton('start', [#'Args'{arg = wrap_args(Module, Args)}], Context),
#'StartResult'{id = ID} = Response,
ID.
-spec call(module(), id(), term(), opts()) -> term() | no_return().
call(Module, ID, Args, #{context := Context}) ->
case call_automaton('call', [{id, ID}, wrap_args(Module, Args)], Context) of
{{ok, Response}, _} ->
% should be specific to a processing interface already
case unmarshal_term(Response) of
ok ->
ok;
{ok, Result} ->
Result;
{exception, Exception} ->
throw(Exception)
end;
{{exception, Exception}, _} ->
% TODO: exception mapping
throw(Exception);
{{error, Reason}, _} ->
error(Reason)
end.
-spec get_history(module(), id(), opts()) -> history().
get_history(Module, ID, #{context := Context}) ->
case call_automaton('getHistory', [{id, ID}, #'HistoryRange'{}], Context) of
{{ok, History0}, _} ->
{Module, History} = unwrap_history(unmarshal_history(History0)),
History;
{{exception, Exception}, _} ->
% TODO: exception mapping
throw(Exception);
{{error, Reason}, _} ->
error(Reason)
end.
%%
call_automaton(Function, Args, Context) ->
% TODO: hg_config module, aware of config entry semantics
Url = genlib_app:env(hellgate, automaton_service_url),
Service = {hg_state_processing_thrift, 'Automaton'},
woody_client:call_safe(Context, {Service, Function, Args}, #{url => Url}).
%%
-type func() :: 'processSignal' | 'processCall'.
-spec handle_function(func(), woody_server_thrift_handler:args(), woody_client:context(), []) ->
{ok, term()} | no_return().
handle_function('processSignal', {Args}, Context, _Opts) ->
#'SignalArgs'{signal = {_Type, Signal}, history = History} = Args,
{ok, dispatch_signal(Signal, unmarshal_history(History), opts(Context))};
handle_function('processCall', {Args}, Context, _Opts) ->
#'CallArgs'{call = Payload, history = History} = Args,
{ok, dispatch_call(Payload, unmarshal_history(History), opts(Context))}.
opts(Context) ->
#{context => Context}.
-spec handle_error(woody_t:func(), term(), woody_client:context(), []) ->
_.
handle_error(_Function, _Reason, _Context, _Opts) ->
ok.
%%
-spec dispatch_signal(Signal, hg_machine:history(), opts()) -> Result when
Signal ::
hg_state_processing_thrift:'InitSignal'() |
hg_state_processing_thrift:'TimeoutSignal'() |
hg_state_processing_thrift:'RepairSignal'(),
Result ::
hg_state_processing_thrift:'SignalResult'().
dispatch_signal(#'InitSignal'{id = ID, arg = Payload}, [], _Opts) ->
% TODO: do not ignore `Opts`
{Module, Args} = unwrap_args(Payload),
_ = lager:debug("[machine] [~p] dispatch init (~p: ~p) with history: ~p", [Module, ID, Args, []]),
marshal_signal_result(Module:init(ID, Args), Module);
dispatch_signal(#'TimeoutSignal'{}, History0, _Opts) ->
% TODO: do not ignore `Opts`
% TODO: deducing module from signal payload looks more natural
% opaque payload in every event?
{Module, History} = unwrap_history(History0),
_ = lager:debug("[machine] [~p] dispatch timeout with history: ~p", [Module, History]),
marshal_signal_result(Module:process_signal(timeout, History), Module);
dispatch_signal(#'RepairSignal'{arg = Payload}, History0, _Opts) ->
% TODO: do not ignore `Opts`
{Module, History} = unwrap_history(History0),
Args = unmarshal_term(Payload),
_ = lager:debug("[machine] [~p] dispatch repair (~p) with history: ~p", [Module, Args, History]),
marshal_signal_result(Module:process_signal({repair, Args}, History), Module).
marshal_signal_result({ok, {Event, Action}}, Module) ->
_ = lager:debug("[machine] [~p] result with event = ~p and action = ~p", [Module, Event, Action]),
#'SignalResult'{
ev = wrap_event(Module, Event),
action = Action
}.
-spec dispatch_call(Call, hg_machine:history(), opts()) -> Result when
Call :: hg_state_processing_thrift:'Call'(),
Result :: hg_state_processing_thrift:'CallResult'().
dispatch_call(Payload, History0, _Opts) ->
% TODO: do not ignore `Opts`
% TODO: looks suspicious
{Module, Args} = unwrap_args(Payload),
{Module, History} = unwrap_history(History0),
_ = lager:debug("[machine] [~p] dispatch call (~p) with history: ~p", [Module, Args, History]),
marshal_call_result(Module:process_call(Args, History), Module).
%%
marshal_call_result({ok, Response, {Event, Action}}, Module) ->
_ = lager:debug(
"[machine] [~p] call response = ~p with event = ~p and action = ~p",
[Module, Response, Event, Action]
),
#'CallResult'{
ev = wrap_event(Module, Event),
action = Action,
response = marshal_term(Response)
}.
%%
unmarshal_history(undefined) ->
[];
unmarshal_history(History) ->
[{ID, Body} || #'Event'{id = ID, body = Body} <- History].
unwrap_history(History = [Event | _]) ->
{_ID, {Module, _EventInner}} = unwrap_event(Event),
{Module, [begin {ID, {_, EventInner}} = unwrap_event(E), {ID, EventInner} end || E <- History]}.
wrap_event(Module, EventInner) ->
wrap_args(Module, EventInner).
unwrap_event({ID, Payload}) ->
{ID, unwrap_args(Payload)}.
wrap_args(Module, Args) ->
marshal_term({Module, Args}).
unwrap_args(Payload) ->
unmarshal_term(Payload).
marshal_term(V) ->
term_to_binary(V).
unmarshal_term(B) ->
binary_to_term(B).

View File

@ -0,0 +1,85 @@
-module(hg_machine_action).
-export([new/0]).
-export([instant/0]).
-export([set_timeout/1]).
-export([set_timeout/2]).
-export([set_deadline/1]).
-export([set_deadline/2]).
-export([set_timer/1]).
-export([set_timer/2]).
-export([set_tag/1]).
-export([set_tag/2]).
-include_lib("hg_proto/include/hg_state_processing_thrift.hrl").
%%
-type tag() :: binary().
-type seconds() :: non_neg_integer().
-type datetime_iso8601() :: binary().
-type datetime() :: calendar:datetime() | datetime_iso8601().
-type timer() :: hg_base_thrift:'Timer'().
-type t() :: hg_state_processing_thrift:'ComplexAction'().
-export_type([t/0]).
%%
-spec new() -> t().
new() ->
#'ComplexAction'{}.
-spec instant() -> t().
instant() ->
set_timeout(0, new()).
-spec set_timeout(seconds()) -> t().
set_timeout(Seconds) ->
set_timeout(Seconds, new()).
-spec set_timeout(seconds(), t()) -> t().
set_timeout(Seconds, Action) when is_integer(Seconds) andalso Seconds >= 0 ->
set_timer({timeout, Seconds}, Action).
-spec set_deadline(datetime()) -> t().
set_deadline(Deadline) ->
set_deadline(Deadline, new()).
-spec set_deadline(datetime(), t()) -> t().
set_deadline(Deadline, Action) ->
set_timer({deadline, try_format_dt(Deadline)}, Action).
-spec set_timer(timer()) -> t().
set_timer(Timer) ->
set_timer(Timer, new()).
-spec set_timer(timer(), t()) -> t().
set_timer(Timer, Action = #'ComplexAction'{}) ->
Action#'ComplexAction'{set_timer = #'SetTimerAction'{timer = Timer}}.
-spec set_tag(tag()) -> t().
set_tag(Tag) ->
set_tag(Tag, new()).
-spec set_tag(tag(), t()) -> t().
set_tag(Tag, Action = #'ComplexAction'{}) when is_binary(Tag) andalso byte_size(Tag) > 0 ->
Action#'ComplexAction'{tag = #'TagAction'{tag = Tag}}.
%%
try_format_dt(Datetime = {_, _}) ->
genlib_format:format_datetime_iso8601(Datetime);
try_format_dt(Datetime) when is_binary(Datetime) ->
Datetime.

View File

@ -0,0 +1,38 @@
-module(hg_utils).
-export([shift_datetime/2]).
-export([get_hostname_ip/1]).
%%
-type seconds() :: integer().
-type datetime_iso8601() :: binary().
-type dt() :: calendar:datetime() | datetime_iso8601().
-spec shift_datetime(dt(), seconds()) -> dt().
shift_datetime(Dt, Seconds) when is_binary(Dt) ->
format_dt(shift_datetime(parse_dt(Dt), Seconds));
shift_datetime(Dt = {_, _}, Seconds) ->
calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(Dt) + Seconds).
format_dt(Dt) ->
genlib_format:format_datetime_iso8601(Dt).
parse_dt(Dt) ->
genlib_format:parse_datetime_iso8601(Dt).
%%
-spec get_hostname_ip(Hostname | IP) -> IP when
Hostname :: string(),
IP :: inet:ip_address().
get_hostname_ip(Host) ->
% TODO: respect preferred address family
case inet:getaddr(Host, inet) of
{ok, IP} ->
IP;
{error, Error} ->
exit(Error)
end.

View File

@ -0,0 +1,22 @@
-module(hg_woody_event_handler).
-behaviour(woody_event_handler).
-export([handle_event/3]).
%%
-spec handle_event(EventType, RpcID, EventMeta)
-> _ when
EventType :: woody_event_handler:event_type(),
RpcID :: woody_t:rpc_id(),
EventMeta :: woody_event_handler:event_meta_type().
handle_event(EventType, RpcID, #{status := error, class := Class, reason := Reason, stack := Stack}) ->
lager:error(
maps:to_list(RpcID),
"[server] ~s with ~s:~p at ~s",
[EventType, Class, Reason, genlib_format:format_stacktrace(Stack, [newlines])]
);
handle_event(EventType, RpcID, EventMeta) ->
lager:debug(maps:to_list(RpcID), "[server] ~s: ~p", [EventType, EventMeta]).

View File

@ -1,37 +0,0 @@
-module(hellgate_tests_SUITE).
-include_lib("common_test/include/ct.hrl").
-compile(export_all).
%%
%% tests descriptions
%%
all() ->
[
dummy_test
].
%%
%% starting/stopping
%%
init_per_suite(C) ->
{ok, Apps} = application:ensure_all_started(hellgate),
[{apps, Apps}|C].
init_per_suite(_, _C) ->
[application_stop(App) || App <- proplists:get_value(apps)].
application_stop(App=sasl) ->
%% hack for preventing sasl deadlock
%% http://erlang.org/pipermail/erlang-questions/2014-May/079012.html
error_logger:delete_report_handler(cth_log_redirect),
application:stop(App),
error_logger:add_report_handler(cth_log_redirect),
ok;
application_stop(App) ->
application:stop(App).
%%
%% tests
%%
dummy_test(_C) ->
ok.

View File

@ -0,0 +1,34 @@
-module(hg_ct_helper).
-export([start_app/1]).
-export([start_app/2]).
%%
-type app_name() :: atom().
-spec start_app(app_name()) -> [app_name()].
start_app(lager = AppName) ->
start_app(AppName, [
{async_threshold, 1},
{async_threshold_window, 0},
{error_logger_hwm, 600},
{suppress_application_start_stop, true},
{handlers, [
{lager_common_test_backend, [debug, false]}
]}
]);
start_app(woody = AppName) ->
start_app(AppName, [
{acceptors_pool_size, 4}
]);
start_app(AppName) ->
genlib_app:start_application(AppName).
-spec start_app(app_name(), list()) -> [app_name()].
start_app(AppName, Env) ->
genlib_app:start_application_with(AppName, Env).

View File

@ -0,0 +1,75 @@
-module(hg_dummy_provider).
-behaviour(woody_server_thrift_handler).
-export([handle_function/4]).
-export([handle_error/4]).
-behaviour(hg_test_provider).
-export([get_child_spec/2]).
-export([get_url/2]).
%%
-spec get_child_spec(inet:hostname() | inet:ip_address(), inet:port_number()) ->
supervisor:child_spec().
get_child_spec(Host, Port) ->
{Name, Path, Service} = get_service_spec(),
woody_server:child_spec(
Name,
#{
ip => hg_utils:get_hostname_ip(Host),
port => Port,
net_opts => [],
event_handler => hg_woody_event_handler,
handlers => [{Path, {Service, ?MODULE, []}}]
}
).
-spec get_url(inet:hostname() | inet:ip_address(), inet:port_number()) ->
woody_t:url().
get_url(Host, Port) ->
{_Name, Path, _Service} = get_service_spec(),
iolist_to_binary(["http://", Host, ":", integer_to_list(Port), Path]).
get_service_spec() ->
{?MODULE, "/test/proxy/provider/dummy",
{hg_proxy_provider_thrift, 'ProviderProxy'}}.
%%
-include_lib("hg_proto/include/hg_proxy_provider_thrift.hrl").
-spec handle_function(woody_t:func(), woody_server_thrift_handler:args(), woody_client:context(), []) ->
{ok, term()} | no_return().
handle_function('ProcessPayment', {#'PaymentInfo'{state = undefined}}, _Context, _Opts) ->
{ok, sleep(1, <<"sleeping">>)};
handle_function('ProcessPayment', {#'PaymentInfo'{state = <<"sleeping">>} = PaymentInfo}, _Context, _Opts) ->
{ok, finish(PaymentInfo)};
handle_function('CapturePayment', {PaymentInfo}, _Context, _Opts) ->
{ok, finish(PaymentInfo)};
handle_function('CancelPayment', {PaymentInfo}, _Context, _Opts) ->
{ok, finish(PaymentInfo)}.
finish(#'PaymentInfo'{payment = Payment}) ->
#'ProcessResult'{
intent = {finish, #'FinishIntent'{status = {ok, #'Ok'{}}}},
trx = #'TransactionInfo'{id = Payment#'InvoicePayment'.id}
}.
sleep(Timeout, State) ->
#'ProcessResult'{
intent = {sleep, #'SleepIntent'{timer = {timeout, Timeout}}},
next_state = State
}.
-spec handle_error(woody_t:func(), term(), woody_client:context(), []) ->
_.
handle_error(_Function, _Reason, _Context, _Opts) ->
ok.

View File

@ -0,0 +1,23 @@
-module(hg_test_proxy).
-type host() :: inet:hostname() | inet:ip_address().
-callback get_child_spec(host(), inet:port_number()) -> supervisor:child_spec().
-callback get_url(host(), inet:port_number()) -> woody_t:url().
-export([get_child_spec/3]).
-export([get_url/3]).
%%
-spec get_child_spec(module(), host(), inet:port_number()) ->
supervisor:child_spec().
get_child_spec(Module, Host, Port) ->
Module:get_child_spec(Host, Port).
-spec get_url(module(), host(), inet:port_number()) ->
supervisor:child_spec().
get_url(Module, Host, Port) ->
Module:get_url(Host, Port).

View File

@ -0,0 +1,195 @@
-module(hg_tests_SUITE).
-include_lib("common_test/include/ct.hrl").
-export([all/0]).
-export([init_per_suite/1]).
-export([end_per_suite/1]).
-export([init_per_testcase/2]).
-export([end_per_testcase/2]).
-export([invoice_cancellation/1]).
-export([overdue_invoice_cancelled/1]).
-export([payment_success/1]).
%%
-behaviour(supervisor).
-export([init/1]).
-spec init([]) ->
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
{ok, {#{strategy => one_for_all, intensity => 1, period => 1}, []}}.
%%
-define(config(Key), begin element(2, lists:keyfind(Key, 1, C)) end).
%% tests descriptions
-type config() :: [{atom(), term()}].
-type test_case_name() :: atom().
-spec all() -> [test_case_name()].
all() ->
[
invoice_cancellation,
overdue_invoice_cancelled,
payment_success
].
%% starting/stopping
-spec init_per_suite(config()) -> config().
init_per_suite(C) ->
Host = "localhost",
% Port = rand:uniform(32768) + 32767,
Port = 8042,
RootUrl = "http://" ++ Host ++ ":" ++ integer_to_list(Port),
Apps =
hg_ct_helper:start_app(lager) ++
hg_ct_helper:start_app(woody) ++
hg_ct_helper:start_app(hellgate, [
{host, Host},
{port, Port},
% FIXME:
% You will need up and running mgun reachable at the following url,
% properly configured to serve incoming requests and talk back to
% the test hg instance.
{automaton_service_url, <<"http://localhost:8022/v1/automaton_service">>}
]),
[{root_url, RootUrl}, {apps, lists:reverse(Apps)} | C].
-spec end_per_suite(config()) -> _.
end_per_suite(C) ->
[application:stop(App) || App <- ?config(apps)].
%% tests
-include_lib("hg_proto/include/hg_payment_processing_thrift.hrl").
-define(ev_invoice_status(Status),
#'InvoiceStatusChanged'{invoice = #'Invoice'{status = Status}}).
-define(ev_invoice_status(Status, Details),
#'InvoiceStatusChanged'{invoice = #'Invoice'{status = Status, details = Details}}).
-define(ev_payment_status(PaymentID, Status),
#'InvoicePaymentStatusChanged'{payment = #'InvoicePayment'{id = PaymentID, status = Status}}).
-spec init_per_testcase(test_case_name(), config()) -> config().
init_per_testcase(_Name, C) ->
Client = hg_client:new(?config(root_url), make_userinfo()),
{ok, SupPid} = supervisor:start_link(?MODULE, []),
[{client, Client}, {test_sup, SupPid} | C].
-spec end_per_testcase(test_case_name(), config()) -> config().
end_per_testcase(_Name, C) ->
_ = unlink(?config(test_sup)),
_ = application:set_env(hellgate, provider_proxy_url, undefined),
exit(?config(test_sup), shutdown).
-spec invoice_cancellation(config()) -> _ | no_return().
invoice_cancellation(C) ->
Client = ?config(client),
InvoiceParams = make_invoice_params(<<"rubberduck">>, 10000),
{ok, InvoiceID} = hg_client:create_invoice(InvoiceParams, Client),
{exception, #'InvalidInvoiceStatus'{}} = hg_client:fulfill_invoice(InvoiceID, <<"perfect">>, Client),
ok = hg_client:void_invoice(InvoiceID, <<"whynot">>, Client).
-spec overdue_invoice_cancelled(config()) -> _ | no_return().
overdue_invoice_cancelled(C) ->
Client = ?config(client),
InvoiceParams = make_invoice_params(<<"rubberduck">>, make_due_date(1), 10000),
{ok, InvoiceID} = hg_client:create_invoice(InvoiceParams, Client),
{ok, ?ev_invoice_status(unpaid)} = hg_client:get_next_event(InvoiceID, 3000, Client),
{ok, ?ev_invoice_status(cancelled, <<"overdue">>)} = hg_client:get_next_event(InvoiceID, 3000, Client).
-spec payment_success(config()) -> _ | no_return().
payment_success(C) ->
Client = ?config(client),
ProxyUrl = start_service_handler(hg_dummy_provider, C),
ok = application:set_env(hellgate, provider_proxy_url, ProxyUrl),
InvoiceParams = make_invoice_params(<<"rubberduck">>, make_due_date(5), 42000),
PaymentParams = make_payment_params(),
{ok, InvoiceID} = hg_client:create_invoice(InvoiceParams, Client),
{ok, ?ev_invoice_status(unpaid)} = hg_client:get_next_event(InvoiceID, 3000, Client),
{ok, PaymentID} = hg_client:start_payment(InvoiceID, PaymentParams, Client),
{ok, ?ev_payment_status(PaymentID, pending)} = hg_client:get_next_event(InvoiceID, 3000, Client),
{ok, ?ev_payment_status(PaymentID, succeeded)} = hg_client:get_next_event(InvoiceID, 3000, Client),
% FIXME: will fail when eventlist feature lands in mg
timeout = hg_client:get_next_event(InvoiceID, 3000, Client).
%%
start_service_handler(Module, C) ->
Host = "localhost",
Port = get_random_port(),
ChildSpec = hg_test_proxy:get_child_spec(Module, Host, Port),
{ok, _} = supervisor:start_child(?config(test_sup), ChildSpec),
hg_test_proxy:get_url(Module, Host, Port).
get_random_port() ->
rand:uniform(32768) + 32767.
%%
make_userinfo() ->
#'UserInfo'{id = <<?MODULE_STRING>>}.
make_invoice_params(Product, Cost) ->
make_invoice_params(Product, make_due_date(), Cost).
make_invoice_params(Product, Due, Cost) ->
make_invoice_params(Product, Due, Cost, []).
make_invoice_params(Product, Due, Amount, Context) when is_integer(Amount) ->
make_invoice_params(Product, Due, {Amount, <<"RUB">>}, Context);
make_invoice_params(Product, Due, {Amount, Currency}, Context) ->
#'InvoiceParams'{
product = Product,
amount = Amount,
due = format_datetime(Due),
currency = #'CurrencyRef'{symbolic_code = Currency},
context = term_to_binary(Context)
}.
make_payment_params() ->
{PaymentTool, Session} = make_payment_tool(),
make_payment_params(PaymentTool, Session).
make_payment_params(PaymentTool, Session) ->
#'InvoicePaymentParams'{
payer = #'Payer'{},
payment_tool = PaymentTool,
session = Session
}.
make_payment_tool() ->
{
{bank_card, #'BankCard'{
token = <<"TOKEN42">>,
payment_system = visa,
bin = <<"424242">>,
masked_pan = <<"4242">>
}},
<<"SESSION42">>
}.
make_due_date() ->
make_due_date(24 * 60 * 60).
make_due_date(LifetimeSeconds) ->
genlib_time:unow() + LifetimeSeconds.
format_datetime(Datetime = {_, _}) ->
genlib_format:format_datetime_iso8601(Datetime);
format_datetime(Timestamp) when is_integer(Timestamp) ->
format_datetime(genlib_time:unixtime_to_daytime(Timestamp)).

View File

@ -0,0 +1,3 @@
{erl_opts, [
{parse_transform, lager_transform}
]}.

View File

@ -0,0 +1,11 @@
{application, hg_client, [
{description, "Hellgate client"},
{vsn, "0"},
{registered, []},
{applications, [
kernel,
stdlib,
woody,
hg_proto
]}
]}.

View File

@ -0,0 +1,227 @@
-module(hg_client).
-include_lib("hg_proto/include/hg_payment_processing_thrift.hrl").
-export([new/2]).
-export([new/3]).
-export([create_invoice/2]).
-export([get_invoice/2]).
-export([fulfill_invoice/3]).
-export([void_invoice/3]).
-export([start_payment/3]).
-export([get_next_event/2]).
-export([get_next_event/3]).
-export_type([t/0]).
%%
-behaviour(gen_server).
-export([init/1]).
-export([handle_call/3]).
-export([handle_cast/2]).
-export([handle_info/2]).
-export([terminate/2]).
-export([code_change/3]).
%%
-behaviour(woody_event_handler).
-export([handle_event/3]).
%%
-define(POLL_INTERVAL, 1000).
-define(DEFAULT_NEXT_EVENT_TIMEOUT, 5000).
-opaque t() :: pid().
-type user_info() :: hg_payment_processing_thrift:'UserInfo'().
-type invoice_id() :: hg_domain_thrift:'InvoiceID'().
-type payment_id() :: hg_domain_thrift:'InvoicePaymentID'().
-type event_id() :: hg_payment_processing_thrift:'EventID'().
-type invoice_params() :: hg_payment_processing_thrift:'InvoiceParams'().
-type payment_params() :: hg_payment_processing_thrift:'InvoicePaymentParams'().
-spec new(woody_t:url(), user_info()) -> t().
new(RootUrl, UserInfo) ->
new(RootUrl, UserInfo, construct_context()).
construct_context() ->
ReqID = genlib_format:format_int_base(genlib_time:ticks(), 62),
woody_client:new_context(ReqID, ?MODULE).
-spec new(woody_t:url(), user_info(), woody_client:context()) -> t().
new(RootUrl, UserInfo, Context) ->
{ok, Pid} = gen_server:start_link(?MODULE, {RootUrl, UserInfo, Context}, []),
Pid.
%%
-spec create_invoice(invoice_params(), t()) ->
{{ok, invoice_id()} | woody_client:result_error(), t()}.
create_invoice(InvoiceParams, Client) ->
do_service_call(Client, 'Create', [InvoiceParams]).
-spec get_invoice(invoice_id(), t()) ->
{{ok, hg_payment_processing_thrift:'InvoiceState'()} | woody_client:result_error(), t()}.
get_invoice(InvoiceID, Client) ->
do_service_call(Client, 'Get', [InvoiceID]).
-spec fulfill_invoice(invoice_id(), binary(), t()) ->
{ok | woody_client:result_error(), t()}.
fulfill_invoice(InvoiceID, Reason, Client) ->
do_service_call(Client, 'Fulfill', [InvoiceID, Reason]).
-spec void_invoice(invoice_id(), binary(), t()) ->
{ok | woody_client:result_error(), t()}.
void_invoice(InvoiceID, Reason, Client) ->
do_service_call(Client, 'Void', [InvoiceID, Reason]).
-spec start_payment(invoice_id(), payment_params(), t()) ->
{{ok, payment_id()} | woody_client:result_error(), t()}.
start_payment(InvoiceID, PaymentParams, Client) ->
do_service_call(Client, 'StartPayment', [InvoiceID, PaymentParams]).
-spec get_next_event(invoice_id(), t()) ->
{{ok, tuple()} | timeout | woody_client:result_error(), t()}.
get_next_event(InvoiceID, Client) ->
get_next_event(InvoiceID, ?DEFAULT_NEXT_EVENT_TIMEOUT, Client).
-spec get_next_event(invoice_id(), timeout(), t()) ->
{{ok, tuple()} | timeout | woody_client:result_error(), t()}.
get_next_event(InvoiceID, Timeout, Client) ->
% FIXME: infinity sounds dangerous
gen_server:call(Client, {get_next_event, InvoiceID, Timeout}, infinity).
do_service_call(Client, Function, Args) ->
% FIXME: infinity sounds dangerous
gen_server:call(Client, {issue_service_call, Function, Args}, infinity).
%%
-record(cl, {
root_url :: woody_t:url(),
user_info :: user_info(),
context :: woody_client:context(),
last_events = #{} :: #{invoice_id() => event_id()}
}).
-type cl() :: #cl{}.
-type callref() :: {pid(), Tag :: reference()}.
-spec init({woody_t:url(), user_info(), woody_client:context()}) ->
{ok, cl()}.
init({RootUrl, UserInfo, Context}) ->
{ok, #cl{context = Context, user_info = UserInfo, root_url = RootUrl}}.
-spec handle_call(term(), callref(), cl()) ->
{reply, term(), cl()} | {noreply, cl()}.
handle_call({issue_service_call, Function, Args}, _From, Client) ->
{Result, ClientNext} = issue_service_call(Function, [get_user_info(Client) | Args], Client),
{reply, Result, ClientNext};
handle_call({get_next_event, InvoiceID, Timeout}, _From, Client) ->
{Result, ClientNext} = poll_next_event(InvoiceID, Timeout, Client),
{reply, Result, ClientNext};
handle_call(Call, _From, State) ->
_ = lager:warning("unexpected call received: ~tp", [Call]),
{noreply, State}.
-spec handle_cast(_, cl()) ->
{noreply, cl()}.
handle_cast(Cast, State) ->
_ = lager:warning("unexpected cast received: ~tp", [Cast]),
{noreply, State}.
-spec handle_info(_, cl()) ->
{noreply, cl()}.
handle_info(Info, State) ->
_ = lager:warning("unexpected info received: ~tp", [Info]),
{noreply, State}.
-spec terminate(Reason, cl()) ->
ok when
Reason :: normal | shutdown | {shutdown, term()} | term().
terminate(_Reason, _State) ->
ok.
-spec code_change(Vsn | {down, Vsn}, cl(), term()) ->
{error, noimpl} when
Vsn :: term().
code_change(_OldVsn, _State, _Extra) ->
{error, noimpl}.
%%
poll_next_event(_InvoiceID, Timeout, Client) when Timeout =< 0 ->
{timeout, Client};
poll_next_event(InvoiceID, Timeout, Client) ->
StartTs = genlib_time:ticks(),
UserInfo = get_user_info(Client),
Range = construct_range(InvoiceID, Client),
{Result, ClientNext} = issue_service_call('GetEvents', [UserInfo, InvoiceID, Range], Client),
case Result of
{ok, []} ->
_ = timer:sleep(?POLL_INTERVAL),
poll_next_event(InvoiceID, compute_timeout_left(StartTs, Timeout), ClientNext);
{ok, [#'Event'{id = EventID, ev = {_, Event}} | _Rest]} ->
{{ok, Event}, update_last_events(InvoiceID, EventID, ClientNext)};
{What, _} when What =:= exception; What =:= error ->
{Result, ClientNext}
end.
construct_range(InvoiceID, #cl{last_events = LastEvents}) ->
#'EventRange'{'after' = genlib_map:get(InvoiceID, LastEvents), limit = 1}.
update_last_events(InvoiceID, EventID, Client = #cl{last_events = LastEvents}) ->
Client#cl{last_events = LastEvents#{InvoiceID => EventID}}.
issue_service_call(Function, Args, Client = #cl{context = Context, root_url = RootUrl}) ->
{_Name, Path, Service} = hg_proto:get_service_spec(invoicing),
Url = iolist_to_binary([RootUrl, Path]),
Request = {Service, Function, Args},
{Result, ContextNext} = woody_client:call_safe(Context, Request, #{url => Url}),
{Result, Client#cl{context = ContextNext}}.
compute_timeout_left(StartTs, TimeoutWas) ->
TimeoutWas - (genlib_time:ticks() - StartTs) div 1000.
get_user_info(#cl{user_info = UserInfo}) ->
UserInfo.
%%
-spec handle_event(EventType, RpcID, EventMeta)
-> _ when
EventType :: woody_event_handler:event_type(),
RpcID :: woody_t:rpc_id(),
EventMeta :: woody_event_handler:event_meta_type().
handle_event(EventType, RpcID, #{status := error, class := Class, reason := Reason, stack := Stack}) ->
lager:error(
maps:to_list(RpcID),
"[client] ~s with ~s:~p at ~s",
[EventType, Class, Reason, genlib_format:format_stacktrace(Stack, [newlines])]
);
handle_event(EventType, RpcID, EventMeta) ->
lager:debug(maps:to_list(RpcID), "[client] ~s: ~p", [EventType, EventMeta]).

2
apps/hg_proto/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
include/hg_*_thrift.hrl
src/hg_*_thrift.erl

1
apps/hg_proto/damsel Submodule

@ -0,0 +1 @@
Subproject commit 24a247b6baa964445164bde5902a1285ed803b16

View File

@ -0,0 +1,21 @@
{plugins, [
{rebar3_thrift_compiler,
{git, "https://github.com/rbkmoney/rebar3_thrift_compiler.git", {tag, "0.2"}}}
]}.
{provider_hooks, [
{pre, [
{compile, {thrift, compile}},
{clean, {thrift, clean}}
]}
]}.
{thrift_compiler_opts, [
{in_dir, "damsel/proto"},
{in_files, [
"state_processing.thrift",
"payment_processing.thrift",
"proxy_provider.thrift"
]},
{gen, "erlang:app_prefix=hg"}
]}.

View File

@ -0,0 +1,10 @@
{application, hg_proto, [
{description, "Processing protocol definitions"},
{vsn, "0"},
{registered, []},
{applications, [
kernel,
stdlib,
thrift
]}
]}.

View File

@ -0,0 +1,26 @@
-module(hg_proto).
-export([get_service_specs/0]).
-export([get_service_spec/1]).
-export_type([service_spec/0]).
%%
-type service_spec() :: {Name :: atom(), Path :: string(), Service :: {module(), atom()}}.
-spec get_service_specs() -> [service_spec()].
get_service_specs() ->
VersionPrefix = "/v1",
[
{invoicing, VersionPrefix ++ "/processing/invoicing",
{hg_payment_processing_thrift, 'Invoicing'}},
{processor, VersionPrefix ++ "/stateproc/processor",
{hg_state_processing_thrift, 'Processor'}}
].
-spec get_service_spec(Name :: atom()) -> service_spec() | false.
get_service_spec(Name) ->
lists:keyfind(Name, 1, get_service_specs()).

View File

@ -0,0 +1,68 @@
-module(hg_proto_utils).
-export([serialize/2]).
-export([deserialize/2]).
%%
%% TODO: move it to the thrift runtime lib?
-type thrift_type() ::
thrift_base_type() |
thrift_collection_type() |
thrift_enum_type() |
thrift_struct_type().
-type thrift_base_type() ::
bool |
double |
i8 |
i16 |
i32 |
i64 |
string.
-type thrift_collection_type() ::
{list, thrift_type()} |
{set, thrift_type()} |
{map, thrift_type(), thrift_type()}.
-type thrift_enum_type() ::
{enum, thrift_type_ref()}.
-type thrift_struct_type() ::
{struct, thrift_struct_flavor(), thrift_type_ref()}.
-type thrift_struct_flavor() :: struct | union | exception.
-type thrift_type_ref() :: {module(), Name :: atom()}.
%%
-spec serialize(thrift_type(), term()) -> {ok, binary()} | {error, any()}.
serialize(Type, Data) ->
{ok, Trans} = thrift_membuffer_transport:new(),
{ok, Proto} = new_protocol(Trans),
case thrift_protocol:write(Proto, {Type, Data}) of
{NewProto, ok} ->
{_, Result} = thrift_protocol:close_transport(NewProto),
{ok, Result};
{_NewProto, {error, _Reason} = Error} ->
Error
end.
-spec deserialize(thrift_type(), binary()) -> {ok, term()} | {error, any()}.
deserialize(Type, Data) ->
{ok, Trans} = thrift_membuffer_transport:new(Data),
{ok, Proto} = new_protocol(Trans),
case thrift_protocol:read(Proto, Type) of
{_NewProto, {ok, Result}} ->
{ok, Result};
{_NewProto, {error, _Reason} = Error} ->
Error
end.
new_protocol(Trans) ->
thrift_binary_protocol:new(Trans, [{strict_read, true}, {strict_write, true}]).

View File

@ -1,3 +1,14 @@
[
{hellgate, []}
{lager, [
{error_logger_hwm, 600},
{handlers, [
{lager_console_backend, debug}
]}
]},
{hellgate, [
{host, "0.0.0.0"},
{port, 8042},
{automaton_service_url, <<"http://localhost:8022/v1/automaton_service">>}
]}
].

70
elvis.config Normal file
View File

@ -0,0 +1,70 @@
[
{elvis, [
{config, [
#{
dirs => [
"apps/*/src",
"apps/*/test"
],
filter => "*.erl",
ignore => ["_thrift.erl$"],
rules => [
{elvis_style, line_length, #{limit => 120, skip_comments => false}},
{elvis_style, no_tabs},
{elvis_style, no_trailing_whitespace},
{elvis_style, macro_module_names},
{elvis_style, operator_spaces, #{rules => [{right, ","}, {right, "++"}, {left, "++"}]}},
{elvis_style, nesting_level, #{level => 3}},
{elvis_style, god_modules, #{limit => 25}},
{elvis_style, no_if_expression},
{elvis_style, invalid_dynamic_call, #{ignore => [elvis]}},
{elvis_style, used_ignored_variable},
{elvis_style, no_behavior_info},
{elvis_style, module_naming_convention, #{regex => "^([a-z][a-z0-9]*_?)*(_SUITE)?$"}},
{elvis_style, function_naming_convention, #{regex => "^([a-z][a-z0-9]*_?)*$"}},
{elvis_style, state_record_and_type},
{elvis_style, no_spec_with_records},
{elvis_style, dont_repeat_yourself, #{min_complexity => 10}},
{elvis_style, no_debug_call, #{ignore => [elvis, elvis_utils]}}
]
},
#{
dirs => ["."],
filter => "Makefile",
ruleset => makefiles
},
#{
dirs => ["."],
filter => "elvis.config",
ruleset => elvis_config
},
#{
dirs => ["apps", "apps/*"],
filter => "rebar.config",
rules => [
{elvis_style, line_length, #{limit => 120, skip_comments => false}},
{elvis_style, no_tabs},
{elvis_style, no_trailing_whitespace}
]
},
#{
dirs => ["."],
filter => "rebar.config",
rules => [
{elvis_style, line_length, #{limit => 120, skip_comments => false}},
{elvis_style, no_tabs},
{elvis_style, no_trailing_whitespace}
]
},
#{
dirs => ["apps/*/src"],
filter => "*.app.src",
rules => [
{elvis_style, line_length, #{limit => 120, skip_comments => false}},
{elvis_style, no_tabs},
{elvis_style, no_trailing_whitespace}
]
}
]}
]}
].

24
packer.json Normal file
View File

@ -0,0 +1,24 @@
{
"builders": [
{
"type": "docker",
"image": "rbkmoney/service_erlang",
"pull": "true",
"commit": "true"
}
],
"provisioners": [
{
"type": "file",
"source": "./_build/prod/rel/hellgate",
"destination": "/opt/"
}
],
"post-processors": [
{
"type": "docker-tag",
"repository": "rbkmoney/hellgate"
}
]
}

View File

@ -1,15 +1,36 @@
{plugins, [
rebar3_run
]}.
% Common project erlang options.
{erl_opts, [
% mandatory
debug_info,
warnings_as_errors
warnings_as_errors,
warn_export_all,
warn_missing_spec,
warn_untyped_record,
warn_export_vars,
% by default
warn_unused_record,
warn_bif_clash,
warn_obsolete_guard,
warn_unused_vars,
warn_shadow_vars,
warn_unused_import,
warn_unused_function,
warn_deprecated_function
% at will
% bin_opt_info
% no_auto_import
% warn_missing_spec_all
]}.
% Common project dependencies.
{deps, [
{lager, "3.0.2"},
{genlib, {git, "https://github.com/rbkmoney/genlib.git", {branch, "master"}}},
{woody, {git, "git@github.com:rbkmoney/woody_erlang.git", {branch, "master"}}}
]}.
{xref_checks, [
@ -20,9 +41,9 @@
]}.
{relx, [
{release, {hellgate, "0.1.0"}, [
hellgate,
sasl
{release, {hellgate, "0.1"}, [
sasl,
hellgate
]},
{sys_config, "./config/sys.config"},
{vm_args, "./config/vm.args"},
@ -31,6 +52,17 @@
{extended_start_script, true}
]}.
{dialyzer, [
{warnings, [
% mandatory
unmatched_returns,
error_handling,
race_conditions,
unknown
]},
{plt_apps, all_deps}
]}.
{profiles, [
{prod, [
{relx, [
@ -42,3 +74,7 @@
{deps, []}
]}
]}.
{plugins, [
rebar3_run
]}.

View File

@ -1 +1,27 @@
[].
[{<<"certifi">>,{pkg,<<"certifi">>,<<"0.4.0">>},2},
{<<"cowboy">>,{pkg,<<"cowboy">>,<<"1.0.4">>},1},
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.0.2">>},2},
{<<"genlib">>,
{git,"https://github.com/rbkmoney/genlib.git",
{ref,"66db7fe296465a875b6894eb5ac944c90f82f913"}},
0},
{<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.7">>},1},
{<<"hackney">>,{pkg,<<"hackney">>,<<"1.5.7">>},1},
{<<"idna">>,{pkg,<<"idna">>,<<"1.2.0">>},2},
{<<"lager">>,{pkg,<<"lager">>,<<"3.0.2">>},0},
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2},
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.0.2">>},2},
{<<"ranch">>,{pkg,<<"ranch">>,<<"1.2.1">>},2},
{<<"snowflake">>,
{git,"https://github.com/tel/snowflake.git",
{ref,"7a8eab0f12757133623b2151a7913b6d2707b629"}},
1},
{<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.0">>},2},
{<<"thrift">>,
{git,"https://github.com/rbkmoney/thrift_erlang.git",
{ref,"4950a4cbb2d79f400a54664cf58e843fd8efcd59"}},
1},
{<<"woody">>,
{git,"git@github.com:rbkmoney/woody_erlang.git",
{ref,"bf74d4615060b776d423e138d257bc75de4733cb"}},
0}].

View File

@ -1,4 +1,8 @@
box: erlang:18
box:
id: rbkmoney/build
username: $CI_BOT_GIT_USERNAME
password: $CI_BOT_GIT_PASSWORD
tag: latest
dev:
steps:
@ -10,6 +14,14 @@ build:
- script:
name: rebar update
code: make rebar-update
- script:
name: lint
code: |
export ELVIS_VERSION="0.2.11"
export ELVIS_PATH="/usr/local/bin/elvis"
curl -sL -o "${ELVIS_PATH}" "https://github.com/inaka/elvis/releases/download/${ELVIS_VERSION}/elvis"
chmod +x "${ELVIS_PATH}"
make lint
- script:
name: run xref
code: make xref