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:
ndiezel0 2023-10-19 14:21:53 +02:00 committed by GitHub
parent fe5ed51e11
commit 58ddb862f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 301 additions and 90 deletions

2
.env
View File

@ -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

View 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.

View File

@ -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.

View File

@ -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}.

View File

@ -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).

View File

@ -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:

View File

@ -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",