mirror of
https://github.com/valitydev/hellgate.git
synced 2024-11-06 02:45:20 +00:00
TD-763: Add UI Cascade as default (#96)
* TD-763: Add UI Cascade as default * Fixes * Add tests * Fix format * Bump OTP version * Be less strict about OTP version * Refactor cascade decision making * New get_route_cascade_behaviour function
This commit is contained in:
parent
fe5ed51e11
commit
58ddb862f7
2
.env
2
.env
@ -2,6 +2,6 @@
|
||||
# You SHOULD specify point releases here so that build time and run time Erlang/OTPs
|
||||
# are the same. See: https://github.com/erlware/relx/pull/902
|
||||
SERVICE_NAME=hellgate
|
||||
OTP_VERSION=24.2.0
|
||||
OTP_VERSION=24.3.4
|
||||
REBAR_VERSION=3.18
|
||||
THRIFT_VERSION=0.14.2.3
|
||||
|
167
apps/hellgate/src/hg_cascade.erl
Normal file
167
apps/hellgate/src/hg_cascade.erl
Normal file
@ -0,0 +1,167 @@
|
||||
-module(hg_cascade).
|
||||
|
||||
-include_lib("damsel/include/dmsl_domain_thrift.hrl").
|
||||
-include_lib("damsel/include/dmsl_user_interaction_thrift.hrl").
|
||||
|
||||
-type trigger_status() :: triggered | not_triggered | negative_trigger.
|
||||
-type cascade_behaviour() :: dmsl_domain_thrift:'CascadeBehaviour'().
|
||||
-type operation_failure() :: dmsl_domain_thrift:'OperationFailure'().
|
||||
|
||||
-export([is_triggered/4]).
|
||||
|
||||
-spec is_triggered(
|
||||
cascade_behaviour() | undefined,
|
||||
operation_failure(),
|
||||
hg_routing:payment_route(),
|
||||
[hg_session:t()]
|
||||
) ->
|
||||
boolean().
|
||||
is_triggered(undefined, _OperationFailure, Route, Sessions) ->
|
||||
handle_trigger_check(is_user_interaction_triggered_(Route, Sessions));
|
||||
is_triggered(
|
||||
#domain_CascadeBehaviour{
|
||||
mapped_errors = MappedErrors,
|
||||
no_user_interaction = NoUI
|
||||
},
|
||||
OperationFailure,
|
||||
Route,
|
||||
Sessions
|
||||
) ->
|
||||
TriggerStatuses = [
|
||||
is_mapped_errors_triggered(MappedErrors, OperationFailure),
|
||||
is_user_interaction_triggered(NoUI, Route, Sessions)
|
||||
],
|
||||
handle_trigger_check(lists:foldl(fun trigger_reduction/2, not_triggered, TriggerStatuses)).
|
||||
|
||||
handle_trigger_check(triggered) ->
|
||||
true;
|
||||
handle_trigger_check(not_triggered) ->
|
||||
false;
|
||||
handle_trigger_check(negative_trigger) ->
|
||||
false.
|
||||
|
||||
is_user_interaction_triggered(undefined, _, _) ->
|
||||
not_trigger;
|
||||
is_user_interaction_triggered(
|
||||
#domain_CascadeWhenNoUI{}, Route, Sessions
|
||||
) ->
|
||||
is_user_interaction_triggered_(Route, Sessions).
|
||||
|
||||
is_user_interaction_triggered_(Route, Sessions) ->
|
||||
lists:foldl(
|
||||
fun(Session, Status) ->
|
||||
case Session of
|
||||
#{route := Route, interaction := Interaction} when Interaction =/= undefined ->
|
||||
negative_trigger;
|
||||
_ ->
|
||||
Status
|
||||
end
|
||||
end,
|
||||
triggered,
|
||||
Sessions
|
||||
).
|
||||
|
||||
is_mapped_errors_triggered(undefined, _) ->
|
||||
not_triggered;
|
||||
is_mapped_errors_triggered(#domain_CascadeOnMappedErrors{error_signatures = Signatures}, {failure, Failure}) ->
|
||||
case failure_matches_any_transient(Failure, ordsets:to_list(Signatures)) of
|
||||
true ->
|
||||
triggered;
|
||||
false ->
|
||||
negative_trigger
|
||||
end;
|
||||
is_mapped_errors_triggered(#domain_CascadeOnMappedErrors{}, {operation_timeout, _}) ->
|
||||
negative_trigger.
|
||||
|
||||
failure_matches_any_transient(Failure, TransientErrorsList) ->
|
||||
lists:any(
|
||||
fun(ExpectNotation) ->
|
||||
payproc_errors:match_notation(Failure, fun
|
||||
(Notation) when binary_part(Notation, {0, byte_size(ExpectNotation)}) =:= ExpectNotation -> true;
|
||||
(_) -> false
|
||||
end)
|
||||
end,
|
||||
TransientErrorsList
|
||||
).
|
||||
|
||||
-spec trigger_reduction(trigger_status(), trigger_status()) -> trigger_status().
|
||||
trigger_reduction(_, negative_trigger) ->
|
||||
negative_trigger;
|
||||
trigger_reduction(negative_trigger, _) ->
|
||||
negative_trigger;
|
||||
trigger_reduction(triggered, _) ->
|
||||
triggered;
|
||||
trigger_reduction(not_triggered, triggered) ->
|
||||
triggered;
|
||||
trigger_reduction(not_triggered, not_triggered) ->
|
||||
not_triggered.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-spec test() -> _.
|
||||
|
||||
-spec failure_matches_any_transient_test_() -> [_].
|
||||
failure_matches_any_transient_test_() ->
|
||||
TransientErrors = [
|
||||
%% 'preauthorization_failed' with all sub failure codes
|
||||
<<"preauthorization_failed">>,
|
||||
%% only 'rejected_by_inspector:*' sub failure codes
|
||||
<<"rejected_by_inspector:">>,
|
||||
%% 'authorization_failed:whatsgoingon' with sub failure codes
|
||||
<<"authorization_failed:whatsgoingon">>
|
||||
],
|
||||
[
|
||||
%% Does match
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"preauthorization_failed">>},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"preauthorization_failed">>, sub = #domain_SubFailure{code = <<"unknown">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"rejected_by_inspector">>, sub = #domain_SubFailure{code = <<"whatever">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"authorization_failed">>, sub = #domain_SubFailure{code = <<"whatsgoingon">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
%% Does NOT match
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"no_route_found">>},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"no_route_found">>, sub = #domain_SubFailure{code = <<"unknown">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"rejected_by_inspector">>},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"authorization_failed">>, sub = #domain_SubFailure{code = <<"unknown">>}},
|
||||
TransientErrors
|
||||
)
|
||||
)
|
||||
].
|
||||
|
||||
-endif.
|
@ -2231,10 +2231,13 @@ process_result({payment, processing_failure}, Action, St = #st{failure = Failure
|
||||
NewAction = hg_machine_action:set_timeout(0, Action),
|
||||
%% We need to rollback only current route.
|
||||
%% Previously used routes are supposed to have their limits already rolled back.
|
||||
Routes = [get_route(St)],
|
||||
Route = get_route(St),
|
||||
Routes = [Route],
|
||||
_ = rollback_payment_limits(Routes, get_iter(St), St),
|
||||
_ = rollback_payment_cashflow(St),
|
||||
case is_route_cascade_available(?failed(Failure), St) of
|
||||
Revision = get_payment_revision(St),
|
||||
Behaviour = get_route_cascade_behaviour(Route, Revision),
|
||||
case is_route_cascade_available(Behaviour, Route, ?failed(Failure), St) of
|
||||
true -> process_routing(NewAction, St);
|
||||
false -> {done, {[?payment_status_changed(?failed(Failure))], NewAction}}
|
||||
end;
|
||||
@ -3660,32 +3663,24 @@ get_party_client() ->
|
||||
Context = hg_context:get_party_client_context(HgContext),
|
||||
{Client, Context}.
|
||||
|
||||
is_route_cascade_available(?failed(OperationFailure), #st{routes = AttemptedRoutes} = St) ->
|
||||
is_failure_cascade_trigger(OperationFailure) andalso
|
||||
is_route_cascade_available(
|
||||
Behaviour,
|
||||
Route,
|
||||
?failed(OperationFailure),
|
||||
#st{routes = AttemptedRoutes, sessions = Sessions} = St
|
||||
) ->
|
||||
%% We don't care what type of UserInteraction was initiated, as long as there was none
|
||||
SessionsList = lists:flatten(maps:values(Sessions)),
|
||||
hg_cascade:is_triggered(Behaviour, OperationFailure, Route, SessionsList) andalso
|
||||
%% For cascade viability we require at least one more route candidate
|
||||
%% provided by recent routing.
|
||||
length(get_candidate_routes(St)) > 1 andalso
|
||||
length(AttemptedRoutes) < get_routing_attempt_limit(St).
|
||||
|
||||
is_failure_cascade_trigger({failure, Failure}) ->
|
||||
failure_matches_any_transient(Failure, get_transient_errors_list());
|
||||
is_failure_cascade_trigger(_OtherFailure) ->
|
||||
false.
|
||||
|
||||
get_transient_errors_list() ->
|
||||
PaymentConfig = genlib_app:env(hellgate, payment, #{}),
|
||||
maps:get(default_transient_errors, PaymentConfig, [<<"preauthorization_failed">>]).
|
||||
|
||||
failure_matches_any_transient(Failure, TransientErrorsList) ->
|
||||
lists:any(
|
||||
fun(ExpectNotation) ->
|
||||
payproc_errors:match_notation(Failure, fun
|
||||
(Notation) when binary_part(Notation, {0, byte_size(ExpectNotation)}) =:= ExpectNotation -> true;
|
||||
(_) -> false
|
||||
end)
|
||||
end,
|
||||
TransientErrorsList
|
||||
).
|
||||
get_route_cascade_behaviour(Route, Revision) ->
|
||||
ProviderRef = get_route_provider(Route),
|
||||
#domain_Provider{cascade_behaviour = Behaviour} = hg_domain:get(Revision, {provider, ProviderRef}),
|
||||
Behaviour.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
@ -3779,67 +3774,4 @@ filter_out_attempted_routes_test_() ->
|
||||
)
|
||||
].
|
||||
|
||||
-spec failure_matches_any_transient_test_() -> [_].
|
||||
failure_matches_any_transient_test_() ->
|
||||
TransientErrors = [
|
||||
%% 'preauthorization_failed' with all sub failure codes
|
||||
<<"preauthorization_failed">>,
|
||||
%% only 'rejected_by_inspector:*' sub failure codes
|
||||
<<"rejected_by_inspector:">>,
|
||||
%% 'authorization_failed:whatsgoingon' with sub failure codes
|
||||
<<"authorization_failed:whatsgoingon">>
|
||||
],
|
||||
[
|
||||
%% Does match
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"preauthorization_failed">>},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"preauthorization_failed">>, sub = #domain_SubFailure{code = <<"unknown">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"rejected_by_inspector">>, sub = #domain_SubFailure{code = <<"whatever">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assert(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"authorization_failed">>, sub = #domain_SubFailure{code = <<"whatsgoingon">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
%% Does NOT match
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"no_route_found">>},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"no_route_found">>, sub = #domain_SubFailure{code = <<"unknown">>}},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"rejected_by_inspector">>},
|
||||
TransientErrors
|
||||
)
|
||||
),
|
||||
?_assertNot(
|
||||
failure_matches_any_transient(
|
||||
#domain_Failure{code = <<"authorization_failed">>, sub = #domain_SubFailure{code = <<"unknown">>}},
|
||||
TransientErrors
|
||||
)
|
||||
)
|
||||
].
|
||||
|
||||
-endif.
|
||||
|
@ -55,12 +55,14 @@
|
||||
-export([proxy_state/1]).
|
||||
-export([timings/1]).
|
||||
-export([repair_scenario/1]).
|
||||
-export([user_interaction/1]).
|
||||
|
||||
%% API
|
||||
|
||||
-export([set_repair_scenario/2]).
|
||||
-export([set_payment_info/2]).
|
||||
-export([set_trx_info/2]).
|
||||
-export([collect_user_interactions/1]).
|
||||
|
||||
-export([create/0]).
|
||||
-export([deduce_activity/1]).
|
||||
@ -154,8 +156,23 @@ timings(T) ->
|
||||
repair_scenario(T) ->
|
||||
maps:get(repair_scenario, T, undefined).
|
||||
|
||||
-spec user_interaction(t()) -> hg_maybe:maybe(interaction()).
|
||||
user_interaction(T) ->
|
||||
maps:get(interaction, T, undefined).
|
||||
|
||||
%% API
|
||||
|
||||
-spec collect_user_interactions([t()]) -> [interaction()].
|
||||
collect_user_interactions(Ts) ->
|
||||
genlib_list:compact(
|
||||
lists:map(
|
||||
fun(T) ->
|
||||
user_interaction(T)
|
||||
end,
|
||||
Ts
|
||||
)
|
||||
).
|
||||
|
||||
-spec set_repair_scenario(repair_scenario(), t()) -> t().
|
||||
set_repair_scenario(Scenario, Session) ->
|
||||
Session#{repair_scenario => Scenario}.
|
||||
|
@ -194,6 +194,8 @@
|
||||
-export([payment_cascade_fail_wo_available_attempt_limit/1]).
|
||||
-export([payment_cascade_failures/1]).
|
||||
-export([payment_cascade_deadline_failures/1]).
|
||||
-export([payment_cascade_fail_provider_error/1]).
|
||||
-export([payment_cascade_fail_ui/1]).
|
||||
|
||||
%%
|
||||
|
||||
@ -466,7 +468,9 @@ groups() ->
|
||||
payment_big_cascade_success,
|
||||
payment_cascade_fail_wo_available_attempt_limit,
|
||||
payment_cascade_failures,
|
||||
payment_cascade_deadline_failures
|
||||
payment_cascade_deadline_failures,
|
||||
payment_cascade_fail_provider_error,
|
||||
payment_cascade_fail_ui
|
||||
]}
|
||||
].
|
||||
|
||||
@ -698,6 +702,10 @@ init_per_testcase(repair_fail_cash_flow_building_succeeded, C) ->
|
||||
fun override_collect_cashflow/1
|
||||
),
|
||||
init_per_testcase(C);
|
||||
init_per_testcase(payment_cascade_fail_provider_error, C) ->
|
||||
override_domain_fixture(fun two_failing_routes_w_three_attempt_limits_new_behaviour/1, C);
|
||||
init_per_testcase(payment_cascade_fail_ui, C) ->
|
||||
override_domain_fixture(fun routes_ruleset_w_failing_provider_fixture/1, C);
|
||||
init_per_testcase(_Name, C) ->
|
||||
init_per_testcase(C).
|
||||
|
||||
@ -1709,6 +1717,65 @@ two_failing_routes_w_three_attempt_limits(Revision) ->
|
||||
)
|
||||
]).
|
||||
|
||||
two_failing_routes_w_three_attempt_limits_new_behaviour(Revision) ->
|
||||
Brovider =
|
||||
#domain_Provider{abs_account = AbsAccount, accounts = Accounts, terms = Terms} =
|
||||
hg_domain:get(Revision, {provider, ?prv(1)}),
|
||||
Terms1 =
|
||||
Terms#domain_ProvisionTermSet{
|
||||
payments = Terms#domain_ProvisionTermSet.payments#domain_PaymentsProvisionTerms{
|
||||
turnover_limits =
|
||||
{value, [
|
||||
#domain_TurnoverLimit{
|
||||
id = ?LIMIT_ID4,
|
||||
upper_boundary = ?BIG_LIMIT_UPPER_BOUNDARY,
|
||||
domain_revision = Revision
|
||||
}
|
||||
]}
|
||||
}
|
||||
},
|
||||
ProviderProto = #domain_Provider{
|
||||
name = <<"Provider Proto">>,
|
||||
proxy = #domain_Proxy{
|
||||
ref = ?prx(1),
|
||||
additional = #{}
|
||||
},
|
||||
description = <<"No rubber ducks for you!">>,
|
||||
abs_account = AbsAccount,
|
||||
accounts = Accounts,
|
||||
terms = Terms1,
|
||||
cascade_behaviour = #domain_CascadeBehaviour{
|
||||
mapped_errors = #domain_CascadeOnMappedErrors{
|
||||
error_signatures = ordsets:from_list([<<"preauthorization_failed">>])
|
||||
}
|
||||
}
|
||||
},
|
||||
lists:flatten([
|
||||
set_merchant_terms_attempt_limit(?trms(1), 3, Revision),
|
||||
{provider, #domain_ProviderObject{
|
||||
ref = ?prv(1),
|
||||
data = Brovider#domain_Provider{
|
||||
terms = Terms1
|
||||
}
|
||||
}},
|
||||
mk_provider_w_term(?trm(999), <<"Not-Brominal #999">>, ?prv(999), <<"Duck Blocker #999">>, ProviderProto, #{
|
||||
<<"always_fail">> => <<"notpreauthorization_failed:card_blocked">>,
|
||||
<<"override">> => <<"duckblocker_999">>
|
||||
}),
|
||||
mk_provider_w_term(?trm(998), <<"Not-Brominal #998">>, ?prv(998), <<"Duck Blocker #998">>, ProviderProto, #{
|
||||
<<"always_fail">> => <<"preauthorization_failed:card_blocked">>,
|
||||
<<"override">> => <<"duckblocker_998">>
|
||||
}),
|
||||
hg_ct_fixture:construct_payment_routing_ruleset(
|
||||
?ruleset(2),
|
||||
<<"2 routes with failing providers">>,
|
||||
{candidates, [
|
||||
?candidate({constant, true}, ?trm(999)),
|
||||
?candidate({constant, true}, ?trm(998))
|
||||
]}
|
||||
)
|
||||
]).
|
||||
|
||||
merchant_payments_service_terms_wo_attempt_limit(Revision) ->
|
||||
lists:flatten([
|
||||
set_merchant_terms_attempt_limit(?trms(1), 1, Revision),
|
||||
@ -6080,6 +6147,34 @@ payment_big_cascade_success(C) ->
|
||||
%% At the end of this scenario limit must be accounted only once.
|
||||
hg_limiter_helper:assert_payment_limit_amount(?LIMIT_ID4, InitialAccountedAmount + Amount, PaymentFinal, Invoice).
|
||||
|
||||
-spec payment_cascade_fail_provider_error(config()) -> test_return().
|
||||
payment_cascade_fail_provider_error(C) ->
|
||||
Client = cfg(client, C),
|
||||
Amount = 42000,
|
||||
InvoiceID = start_invoice(<<"rubberduck">>, make_due_date(10), Amount, C),
|
||||
{PaymentTool, Session} = hg_dummy_provider:make_payment_tool(no_preauth, ?pmt_sys(<<"visa-ref">>)),
|
||||
PaymentParams = make_payment_params(PaymentTool, Session, instant),
|
||||
hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client),
|
||||
?payment_ev(PaymentID, ?payment_started(?payment_w_status(?pending()))) =
|
||||
next_change(InvoiceID, Client),
|
||||
?payment_ev(PaymentID, ?risk_score_changed(_)) =
|
||||
next_change(InvoiceID, Client),
|
||||
{_Route1, _CashFlow1, _TrxID1, Failure1} =
|
||||
await_cascade_triggering(InvoiceID, PaymentID, Client),
|
||||
%% And again
|
||||
?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure1}))) =
|
||||
next_change(InvoiceID, Client),
|
||||
%% Assert payment status IS failed
|
||||
?invoice_state(?invoice_w_status(_), [?payment_state(Payment)]) =
|
||||
hg_client_invoicing:get(InvoiceID, Client),
|
||||
?assertMatch(#domain_InvoicePayment{status = {failed, _}}, Payment),
|
||||
?invoice_status_changed(?invoice_cancelled(<<"overdue">>)) = next_change(InvoiceID, Client).
|
||||
|
||||
-spec payment_cascade_fail_ui(config()) -> test_return().
|
||||
payment_cascade_fail_ui(_C) ->
|
||||
%% TODO teach hg_dummy_provider how to fail after receiving user_interaction
|
||||
ok.
|
||||
|
||||
-spec payment_cascade_fail_wo_route_candidates(config()) -> test_return().
|
||||
payment_cascade_fail_wo_route_candidates(C) ->
|
||||
payment_cascade_failures(C).
|
||||
|
@ -27,7 +27,7 @@ services:
|
||||
command: /sbin/init
|
||||
|
||||
dominant:
|
||||
image: ghcr.io/valitydev/dominant:sha-486d2ef
|
||||
image: ghcr.io/valitydev/dominant:sha-92c427c
|
||||
command: /opt/dominant/bin/dominant foreground
|
||||
depends_on:
|
||||
machinegun:
|
||||
|
@ -17,7 +17,7 @@
|
||||
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.11.0">>},2},
|
||||
{<<"damsel">>,
|
||||
{git,"https://github.com/valitydev/damsel.git",
|
||||
{ref,"c65fc2e6a829f440a82720b3602b7bab4f30b71d"}},
|
||||
{ref,"caf49f8fe73b35da695726292755309238143f76"}},
|
||||
0},
|
||||
{<<"dmt_client">>,
|
||||
{git,"https://github.com/valitydev/dmt-client.git",
|
||||
|
Loading…
Reference in New Issue
Block a user