diff --git a/.github/workflows/erlang-checks.yaml b/.github/workflows/erlang-checks.yaml index 739f03e..8e8c9be 100644 --- a/.github/workflows/erlang-checks.yaml +++ b/.github/workflows/erlang-checks.yaml @@ -38,4 +38,4 @@ jobs: thrift-version: ${{ needs.setup.outputs.thrift-version }} run-ct-with-compose: true use-coveralls: true - cache-version: v4 + cache-version: v5 diff --git a/apps/hellgate/include/hg_invoice_payment.hrl b/apps/hellgate/include/hg_invoice_payment.hrl index 559dfbd..56f4f1b 100644 --- a/apps/hellgate/include/hg_invoice_payment.hrl +++ b/apps/hellgate/include/hg_invoice_payment.hrl @@ -27,7 +27,9 @@ capture_data :: undefined | hg_invoice_payment:capture_data(), failure :: undefined | hg_invoice_payment:failure(), timings :: undefined | hg_timings:t(), - allocation :: undefined | hg_allocation:allocation() + allocation :: undefined | hg_allocation:allocation(), + route_limits = #{} :: hg_routing:limits(), + route_scores = #{} :: hg_routing:scores() }). -record(refund_st, { diff --git a/apps/hellgate/include/payment_events.hrl b/apps/hellgate/include/payment_events.hrl index 3b3ddbd..bdd2961 100644 --- a/apps/hellgate/include/payment_events.hrl +++ b/apps/hellgate/include/payment_events.hrl @@ -30,7 +30,9 @@ ). -define(route_changed(Route), - {invoice_payment_route_changed, #payproc_InvoicePaymentRouteChanged{route = Route}} + {invoice_payment_route_changed, #payproc_InvoicePaymentRouteChanged{ + route = Route + }} ). -define(route_changed(Route, Candidates), @@ -40,6 +42,15 @@ }} ). +-define(route_changed(Route, Candidates, Scores, Limits), + {invoice_payment_route_changed, #payproc_InvoicePaymentRouteChanged{ + route = Route, + candidates = Candidates, + scores = Scores, + limits = Limits + }} +). + -define(cash_flow_changed(CashFlow), {invoice_payment_cash_flow_changed, #payproc_InvoicePaymentCashFlowChanged{ cash_flow = CashFlow diff --git a/apps/hellgate/src/hellgate.app.src b/apps/hellgate/src/hellgate.app.src index 56a5c29..f9f8348 100644 --- a/apps/hellgate/src/hellgate.app.src +++ b/apps/hellgate/src/hellgate.app.src @@ -11,6 +11,7 @@ genlib, fault_detector_proto, hg_proto, + routing, cowboy, woody, scoper, % should be before any scoper event handler usage diff --git a/apps/hellgate/src/hg_invoice_handler.erl b/apps/hellgate/src/hg_invoice_handler.erl index eee7613..bc75807 100644 --- a/apps/hellgate/src/hg_invoice_handler.erl +++ b/apps/hellgate/src/hg_invoice_handler.erl @@ -140,7 +140,11 @@ handle_function_('RepairWithScenario', {InvoiceID, Scenario}, _Opts) -> handle_function_('GetPaymentRoutesLimitValues', {InvoiceID, PaymentID}, _Opts) -> _ = set_invoicing_meta(InvoiceID, PaymentID), St = get_state(InvoiceID), - hg_invoice_payment:get_limit_values(get_payment_session(PaymentID, St), hg_invoice:get_payment_opts(St)). + hg_invoice_payment:get_limit_values(get_payment_session(PaymentID, St), hg_invoice:get_payment_opts(St)); +handle_function_('ExplainRoute', {InvoiceID, PaymentID}, _Opts) -> + _ = set_invoicing_meta(InvoiceID, PaymentID), + St = get_state(InvoiceID), + hg_routing_explanation:get_explanation(get_payment_session(PaymentID, St), hg_invoice:get_payment_opts(St)). ensure_started(ID, TemplateID, PartyRevision, Params, Allocation) -> Invoice = hg_invoice:create(ID, TemplateID, PartyRevision, Params, Allocation), diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index d35cedf..20a992d 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -102,6 +102,8 @@ -export([construct_payment_plan_id/1]). -export([construct_payment_plan_id/2]). +-export([get_payer_payment_tool/1]). + %% -export_type([payment_id/0]). @@ -1982,18 +1984,22 @@ produce_routing_events(Ctx = #{error := Error}, _Revision, St) when Error =/= un Route = hg_route:to_payment_route(hd(RollbackableCandidates)), Candidates = ordsets:from_list([hg_route:to_payment_route(R) || R <- RollbackableCandidates]), + RouteScores = hg_routing_ctx:route_scores(Ctx), + RouteLimits = hg_routing_ctx:route_limits(Ctx), %% For protocol compatability we set choosen route in route_changed event. %% It doesn't influence cash_flow building because this step will be %% skipped. And all limit's 'hold' operations will be rolled back. %% For same purpose in cascade routing we use route from unfiltered list of %% originally resolved candidates. - [?route_changed(Route, Candidates), ?payment_rollback_started(Failure)]; + [?route_changed(Route, Candidates, RouteScores, RouteLimits), ?payment_rollback_started(Failure)]; produce_routing_events(Ctx, Revision, _St) -> ok = log_route_choice_meta(Ctx, Revision), Route = hg_route:to_payment_route(hg_routing_ctx:choosen_route(Ctx)), Candidates = ordsets:from_list([hg_route:to_payment_route(R) || R <- hg_routing_ctx:considered_candidates(Ctx)]), - [?route_changed(Route, Candidates)]. + RouteScores = hg_routing_ctx:route_scores(Ctx), + RouteLimits = hg_routing_ctx:route_limits(Ctx), + [?route_changed(Route, Candidates, RouteScores, RouteLimits)]. route_args(St) -> Opts = get_opts(St), @@ -2484,9 +2490,10 @@ filter_routes_with_limit_hold(Ctx0, VS, Iter, St) -> Ctx1 = reject_routes(limit_misconfiguration, RejectedRoutes, Ctx0), hg_routing_ctx:stash_current_candidates(Ctx1). -filter_routes_by_limit_overflow(Ctx, VS, St) -> - {_Routes, RejectedRoutes} = get_limit_overflow_routes(hg_routing_ctx:candidates(Ctx), VS, St), - reject_routes(limit_overflow, RejectedRoutes, Ctx). +filter_routes_by_limit_overflow(Ctx0, VS, St) -> + {_Routes, RejectedRoutes, Limits} = get_limit_overflow_routes(hg_routing_ctx:candidates(Ctx0), VS, St), + Ctx1 = hg_routing_ctx:stash_route_limits(Limits, Ctx0), + reject_routes(limit_overflow, RejectedRoutes, Ctx1). reject_routes(GroupReason, RejectedRoutes, Ctx) -> lists:foldr( @@ -2501,19 +2508,19 @@ get_limit_overflow_routes(Routes, VS, St) -> Payment = get_payment(St), Invoice = get_invoice(Opts), lists:foldl( - fun(Route, {RoutesNoOverflowIn, RejectedIn}) -> + fun(Route, {RoutesNoOverflowIn, RejectedIn, LimitsIn}) -> PaymentRoute = hg_route:to_payment_route(Route), ProviderTerms = hg_routing:get_payment_terms(PaymentRoute, VS, Revision), TurnoverLimits = get_turnover_limits(ProviderTerms), case hg_limiter:check_limits(TurnoverLimits, Invoice, Payment, PaymentRoute) of - {ok, _} -> - {[Route | RoutesNoOverflowIn], RejectedIn}; - {error, {limit_overflow, IDs}} -> + {ok, Limits} -> + {[Route | RoutesNoOverflowIn], RejectedIn, LimitsIn#{PaymentRoute => Limits}}; + {error, {limit_overflow, IDs, Limits}} -> RejectedRoute = hg_route:to_rejected_route(Route, {'LimitOverflow', IDs}), - {RoutesNoOverflowIn, [RejectedRoute | RejectedIn]} + {RoutesNoOverflowIn, [RejectedRoute | RejectedIn], LimitsIn#{PaymentRoute => Limits}} end end, - {[], []}, + {[], [], #{}}, Routes ). @@ -2875,6 +2882,7 @@ get_payment_tool(#domain_InvoicePayment{payer = Payer}) -> get_payment_created_at(#domain_InvoicePayment{created_at = CreatedAt}) -> CreatedAt. +-spec get_payer_payment_tool(payer()) -> payment_tool(). get_payer_payment_tool(?payment_resource_payer(PaymentResource, _ContactInfo)) -> get_resource_payment_tool(PaymentResource); get_payer_payment_tool(?customer_payer(_CustomerID, _, _, PaymentTool, _)) -> @@ -2941,7 +2949,11 @@ merge_change(Change = ?risk_score_changed(RiskScore), #st{} = St, Opts) -> risk_score = RiskScore, activity = {payment, routing} }; -merge_change(Change = ?route_changed(Route, Candidates), #st{routes = Routes} = St, Opts) -> +merge_change( + Change = ?route_changed(Route, Candidates, Scores, Limits), + #st{routes = Routes, route_scores = RouteScores, route_limits = RouteLimits} = St, + Opts +) -> _ = validate_transition([{payment, S} || S <- [routing, processing_failure]], Change, St, Opts), St#st{ %% On route change we expect cash flow from previous attempt to be rolled back. @@ -2951,7 +2963,9 @@ merge_change(Change = ?route_changed(Route, Candidates), #st{routes = Routes} = trx = undefined, routes = [Route | Routes], candidate_routes = ordsets:to_list(Candidates), - activity = {payment, cash_flow_building} + activity = {payment, cash_flow_building}, + route_scores = hg_maybe:apply(fun(S) -> maps:merge(RouteScores, S) end, Scores, RouteScores), + route_limits = hg_maybe:apply(fun(L) -> maps:merge(RouteLimits, L) end, Limits, RouteLimits) }; merge_change(Change = ?payment_capture_started(Data), #st{} = St, Opts) -> _ = validate_transition([{payment, S} || S <- [flow_waiting]], Change, St, Opts), diff --git a/apps/hellgate/src/hg_limiter.erl b/apps/hellgate/src/hg_limiter.erl index f2823b2..9868c7a 100644 --- a/apps/hellgate/src/hg_limiter.erl +++ b/apps/hellgate/src/hg_limiter.erl @@ -19,6 +19,8 @@ -type change_queue() :: [hg_limiter_client:limit_change()]. +-export_type([turnover_limit_value/0]). + -export([get_turnover_limits/1]). -export([check_limits/4]). -export([hold_payment_limits/5]). @@ -60,31 +62,33 @@ get_limit_values(TurnoverLimits, Invoice, Payment, Route) -> ). -spec check_limits([turnover_limit()], invoice(), payment(), route()) -> - {ok, [hg_limiter_client:limit()]} - | {error, {limit_overflow, [binary()]}}. + {ok, [turnover_limit_value()]} + | {error, {limit_overflow, [binary()], [turnover_limit_value()]}}. check_limits(TurnoverLimits, Invoice, Payment, Route) -> Context = gen_limit_context(Invoice, Payment, Route), + {ok, Limits} = gather_limits(TurnoverLimits, Context, []), try - check_limits_(TurnoverLimits, Context, []) + ok = check_limits_(Limits, Context), + {ok, Limits} catch throw:limit_overflow -> IDs = [T#domain_TurnoverLimit.id || T <- TurnoverLimits], - {error, {limit_overflow, IDs}} + {error, {limit_overflow, IDs, Limits}} end. -check_limits_([], _, Limits) -> - {ok, Limits}; -check_limits_([T | TurnoverLimits], Context, Acc) -> - #domain_TurnoverLimit{id = LimitID, domain_revision = Version} = T, - Clock = get_latest_clock(), - Limit = hg_limiter_client:get(LimitID, Version, Clock, Context), - #limiter_Limit{ - amount = LimiterAmount - } = Limit, - UpperBoundary = T#domain_TurnoverLimit.upper_boundary, +check_limits_([], _) -> + ok; +check_limits_([TurnoverLimitValue | TLVs], Context) -> + #payproc_TurnoverLimitValue{ + limit = #domain_TurnoverLimit{ + id = LimitID, + upper_boundary = UpperBoundary + }, + value = LimiterAmount + } = TurnoverLimitValue, case LimiterAmount =< UpperBoundary of true -> - check_limits_(TurnoverLimits, Context, [Limit | Acc]); + check_limits_(TLVs, Context); false -> logger:notice("Limit with id ~p overflowed, amount ~p upper boundary ~p", [ LimitID, @@ -94,6 +98,15 @@ check_limits_([T | TurnoverLimits], Context, Acc) -> throw(limit_overflow) end. +gather_limits([], _Context, Acc) -> + {ok, Acc}; +gather_limits([T | TurnoverLimits], Context, Acc) -> + #domain_TurnoverLimit{id = LimitID, domain_revision = Version} = T, + Clock = get_latest_clock(), + #limiter_Limit{amount = Amount} = hg_limiter_client:get(LimitID, Version, Clock, Context), + TurnoverLimitValue = #payproc_TurnoverLimitValue{limit = T, value = Amount}, + gather_limits(TurnoverLimits, Context, [TurnoverLimitValue | Acc]). + -spec hold_payment_limits([turnover_limit()], route(), pos_integer(), invoice(), payment()) -> ok. hold_payment_limits(TurnoverLimits, Route, Iter, Invoice, Payment) -> ChangeIDs = [construct_payment_change_id(Route, Iter, Invoice, Payment)], diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index 12f38e1..85a2ad2 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -6229,7 +6229,7 @@ payment_cascade_success(C) -> next_change(InvoiceID, Client), ?payment_ev(PaymentID, ?risk_score_changed(_)) = next_change(InvoiceID, Client), - {_Route1, _CashFlow1, TrxID1, Failure1} = + {Route1, _CashFlow1, TrxID1, Failure1} = await_cascade_triggering(InvoiceID, PaymentID, Client), ok = payproc_errors:match('PaymentFailure', Failure1, fun({preauthorization_failed, {card_blocked, _}}) -> ok end), %% Assert payment status IS NOT failed @@ -6271,7 +6271,21 @@ payment_cascade_success(C) -> Trx ), %% 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). + _ = hg_limiter_helper:assert_payment_limit_amount( + ?LIMIT_ID4, InitialAccountedAmount + Amount, PaymentFinal, Invoice + ), + #payproc_InvoicePaymentExplanation{ + explained_routes = [ + #payproc_InvoicePaymentRouteExplanation{ + route = Route2, + is_chosen = true + }, + #payproc_InvoicePaymentRouteExplanation{ + route = Route1, + is_chosen = false + } + ] + } = hg_client_invoicing:explain_route(InvoiceID, PaymentID, Client). payment_cascade_success_w_refund_fixture(Revision, _C) -> Brovider = diff --git a/apps/hg_client/src/hg_client_invoicing.erl b/apps/hg_client/src/hg_client_invoicing.erl index 0e56641..c57cc19 100644 --- a/apps/hg_client/src/hg_client_invoicing.erl +++ b/apps/hg_client/src/hg_client_invoicing.erl @@ -42,6 +42,8 @@ -export([compute_terms/3]). +-export([explain_route/3]). + -export([pull_event/2]). -export([pull_event/3]). -export([pull_change/4]). @@ -86,6 +88,8 @@ -type chargeback_reject_params() :: dmsl_payproc_thrift:'InvoicePaymentChargebackRejectParams'(). -type chargeback_reopen_params() :: dmsl_payproc_thrift:'InvoicePaymentChargebackReopenParams'(). +-type invoice_payment_explanation() :: dmsl_payproc_thrift:'InvoicePaymentExplanation'(). + -type term_set() :: dmsl_domain_thrift:'TermSet'(). -type cash() :: undefined | dmsl_domain_thrift:'Cash'(). -type cart() :: undefined | dmsl_domain_thrift:'InvoiceCart'(). @@ -300,6 +304,12 @@ compute_terms(InvoiceID, PartyRevision, Client) -> gen_server:call(Client, {call, 'ComputeTerms', [InvoiceID, PartyRevision], otel_ctx:get_current()}) ). +-spec explain_route(invoice_id(), payment_id(), pid()) -> + invoice_payment_explanation() | woody_error:business_error(). +explain_route(InvoiceID, PaymentID, Client) -> + Args = [InvoiceID, PaymentID], + map_result_error(gen_server:call(Client, {call, 'ExplainRoute', Args, otel_ctx:get_current()})). + -define(DEFAULT_NEXT_EVENT_TIMEOUT, 5000). -spec pull_event(invoice_id(), pid()) -> diff --git a/apps/hellgate/src/hg_route.erl b/apps/routing/src/hg_route.erl similarity index 99% rename from apps/hellgate/src/hg_route.erl rename to apps/routing/src/hg_route.erl index 7459c25..582bdb6 100644 --- a/apps/hellgate/src/hg_route.erl +++ b/apps/routing/src/hg_route.erl @@ -1,5 +1,7 @@ -module(hg_route). +-include_lib("hellgate/include/domain.hrl"). + -export([new/2]). -export([new/4]). -export([new/5]). @@ -20,8 +22,6 @@ %% --include("domain.hrl"). - -record(route, { provider_ref :: dmsl_domain_thrift:'ProviderRef'(), terminal_ref :: dmsl_domain_thrift:'TerminalRef'(), diff --git a/apps/hellgate/src/hg_routing.erl b/apps/routing/src/hg_routing.erl similarity index 95% rename from apps/hellgate/src/hg_routing.erl rename to apps/routing/src/hg_routing.erl index 6b8096b..549bcc8 100644 --- a/apps/hellgate/src/hg_routing.erl +++ b/apps/routing/src/hg_routing.erl @@ -5,6 +5,7 @@ -include_lib("damsel/include/dmsl_domain_thrift.hrl"). -include_lib("damsel/include/dmsl_payproc_thrift.hrl"). -include_lib("fault_detector_proto/include/fd_proto_fault_detector_thrift.hrl"). +-include_lib("hellgate/include/domain.hrl"). -export([gather_routes/5]). -export([rate_routes/1]). @@ -23,8 +24,6 @@ %% --include("domain.hrl"). - -type payment_terms() :: dmsl_domain_thrift:'PaymentsProvisionTerms'(). -type payment_institution() :: dmsl_domain_thrift:'PaymentInstitution'(). -type route_predestination() :: payment | recurrent_paytool | recurrent_payment. @@ -46,8 +45,6 @@ -type conversion_condition() :: normal | lacking. -type conversion_fail_rate() :: float(). --type condition_score() :: 0 | 1. - -type route_groups_by_priority() :: #{{availability_condition(), terminal_priority_rating()} => [fail_rated_route()]}. -type fail_rated_route() :: {hg_route:t(), provider_status()}. @@ -57,7 +54,7 @@ -type route_choice_context() :: #{ chosen_route => hg_route:t(), preferable_route => hg_route:t(), - % Contains one of the field names defined in #route_scores{} + % Contains one of the field names defined in #domain_PaymentRouteScores{} reject_reason => atom() }. @@ -76,28 +73,25 @@ -type varset() :: hg_varset:varset(). -type revision() :: hg_domain:revision(). --record(route_scores, { - availability_condition :: condition_score(), - conversion_condition :: condition_score(), - priority_rating :: terminal_priority_rating(), - pin :: integer(), - random_condition :: integer(), - availability :: float(), - conversion :: float() -}). - --type route_scores() :: #route_scores{}. +-type route_scores() :: #domain_PaymentRouteScores{}. +-type limits() :: #{hg_route:payment_route() => [hg_limiter:turnover_limit_value()]}. +-type scores() :: #{hg_route:payment_route() => hg_routing:route_scores()}. -type misconfiguration_error() :: {misconfiguration, {routing_decisions, _} | {routing_candidate, _}}. -export_type([route_predestination/0]). -export_type([route_choice_context/0]). -export_type([fail_rated_route/0]). +-export_type([route_scores/0]). +-export_type([limits/0]). +-export_type([scores/0]). %% -spec filter_by_critical_provider_status(T) -> T when T :: hg_routing_ctx:t(). -filter_by_critical_provider_status(Ctx) -> - RoutesFailRates = rate_routes(hg_routing_ctx:candidates(Ctx)), +filter_by_critical_provider_status(Ctx0) -> + RoutesFailRates = rate_routes(hg_routing_ctx:candidates(Ctx0)), + RouteScores = score_routes_map(RoutesFailRates), + Ctx1 = hg_routing_ctx:stash_route_scores(RouteScores, Ctx0), lists:foldr( fun ({R, {{dead, _} = AvailabilityStatus, _ConversionStatus}}, C) -> @@ -106,7 +100,7 @@ filter_by_critical_provider_status(Ctx) -> ({_R, _ProviderStatus}, C) -> C end, - hg_routing_ctx:with_fail_rates(RoutesFailRates, Ctx), + hg_routing_ctx:with_fail_rates(RoutesFailRates, Ctx1), RoutesFailRates ). @@ -317,7 +311,7 @@ select_better_route_ideal(Left, Right) -> set_ideal_score({RouteScores, PT}) -> { - RouteScores#route_scores{ + RouteScores#domain_PaymentRouteScores{ availability_condition = 1, availability = 1.0, conversion_condition = 1, @@ -367,11 +361,11 @@ format_logger_metadata(Meta, Route, Revision) when map_route_switch_reason(SameScores, SameScores) -> unknown; map_route_switch_reason(RealScores, IdealScores) when - is_record(RealScores, route_scores); is_record(IdealScores, route_scores) + is_record(RealScores, 'domain_PaymentRouteScores'); is_record(IdealScores, 'domain_PaymentRouteScores') -> Zipped = lists:zip(tuple_to_list(RealScores), tuple_to_list(IdealScores)), DifferenceIdx = find_idx_of_difference(Zipped), - lists:nth(DifferenceIdx, record_info(fields, route_scores)). + lists:nth(DifferenceIdx, record_info(fields, 'domain_PaymentRouteScores')). find_idx_of_difference(ZippedList) -> find_idx_of_difference(ZippedList, 0). @@ -438,6 +432,16 @@ calc_random_condition(StartFrom, Random, [FailRatedRoute | Rest], Routes) -> calc_random_condition(StartFrom + Weight, Random, Rest, [{NewRoute, Status} | Routes]) end. +-spec score_routes_map([fail_rated_route()]) -> #{hg_route:payment_route() => route_scores()}. +score_routes_map(Routes) -> + lists:foldl( + fun({Route, _} = FailRatedRoute, Acc) -> + Acc#{hg_route:to_payment_route(Route) => score_route(FailRatedRoute)} + end, + #{}, + Routes + ). + -spec score_routes([fail_rated_route()]) -> [scored_route()]. score_routes(Routes) -> [{score_route(FailRatedRoute), Route} || {Route, _} = FailRatedRoute <- Routes]. @@ -450,11 +454,11 @@ score_route({Route, ProviderStatus}) -> {AvailabilityStatus, ConversionStatus} = ProviderStatus, {AvailabilityCondition, Availability} = get_availability_score(AvailabilityStatus), {ConversionCondition, Conversion} = get_conversion_score(ConversionStatus), - #route_scores{ + #domain_PaymentRouteScores{ availability_condition = AvailabilityCondition, conversion_condition = ConversionCondition, - priority_rating = PriorityRate, - pin = PinHash, + terminal_priority_rating = PriorityRate, + route_pin = PinHash, random_condition = RandomCondition, availability = Availability, conversion = Conversion @@ -737,25 +741,25 @@ getv(Name, VS, Default) -> -spec record_comparsion_test() -> _. record_comparsion_test() -> Bigger = { - #route_scores{ + #domain_PaymentRouteScores{ availability_condition = 1, availability = 0.5, conversion_condition = 1, conversion = 0.5, - priority_rating = 1, - pin = 0, + terminal_priority_rating = 1, + route_pin = 0, random_condition = 1 }, {42, 42} }, Smaller = { - #route_scores{ + #domain_PaymentRouteScores{ availability_condition = 0, availability = 0.1, conversion_condition = 1, conversion = 0.5, - priority_rating = 1, - pin = 0, + terminal_priority_rating = 1, + route_pin = 0, random_condition = 1 }, {99, 99} diff --git a/apps/hellgate/src/hg_routing_ctx.erl b/apps/routing/src/hg_routing_ctx.erl similarity index 90% rename from apps/hellgate/src/hg_routing_ctx.erl rename to apps/routing/src/hg_routing_ctx.erl index 1653208..26d7d6e 100644 --- a/apps/hellgate/src/hg_routing_ctx.erl +++ b/apps/routing/src/hg_routing_ctx.erl @@ -18,9 +18,15 @@ -export([process/2]). -export([with_guard/1]). -export([pipeline/2]). +-export([route_limits/1]). +-export([stash_route_limits/2]). +-export([route_scores/1]). +-export([stash_route_scores/2]). -type rejection_group() :: atom(). -type error() :: {atom(), _Description}. +-type route_limits() :: hg_routing:limits(). +-type route_scores() :: hg_routing:scores(). -type t() :: #{ initial_candidates := [hg_route:t()], @@ -31,7 +37,9 @@ choosen_route := hg_route:t() | undefined, choice_meta := hg_routing:route_choice_context() | undefined, stashed_candidates => [hg_route:t()], - fail_rates => [hg_routing:fail_rated_route()] + fail_rates => [hg_routing:fail_rated_route()], + route_limits => route_limits(), + route_scores => route_scores() }. -export_type([t/0]). @@ -152,6 +160,24 @@ rejections(#{rejections := Rejections}) -> %% +-spec route_limits(t()) -> route_limits() | undefined. +route_limits(Ctx) -> + maps:get(route_limits, Ctx, undefined). + +-spec stash_route_limits(route_limits(), t()) -> t(). +stash_route_limits(RouteLimits, Ctx) -> + Ctx#{route_limits => RouteLimits}. + +-spec route_scores(t()) -> route_scores() | undefined. +route_scores(Ctx) -> + maps:get(route_scores, Ctx, undefined). + +-spec stash_route_scores(route_scores(), t()) -> t(). +stash_route_scores(RouteScores, Ctx) -> + Ctx#{route_scores => RouteScores}. + +%% + latest_rejected_routes(#{latest_rejection := ReasonGroup, rejections := Rejections}) -> {ReasonGroup, maps:get(ReasonGroup, Rejections, [])}. diff --git a/apps/routing/src/hg_routing_explanation.erl b/apps/routing/src/hg_routing_explanation.erl new file mode 100644 index 0000000..904841d --- /dev/null +++ b/apps/routing/src/hg_routing_explanation.erl @@ -0,0 +1,285 @@ +-module(hg_routing_explanation). + +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). +-include_lib("damsel/include/dmsl_payproc_thrift.hrl"). +-include_lib("hellgate/include/hg_invoice_payment.hrl"). + +%% API +-export([get_explanation/2]). + +-type st() :: #st{}. +-type explanation() :: dmsl_payproc_thrift:'InvoicePaymentExplanation'(). + +-type route() :: hg_route:payment_route(). +-type scores() :: hg_routing:scores(). +-type limits() :: hg_routing:limits(). +-type route_with_context() :: #{ + route := route(), + scores := hg_routing:route_scores() | undefined, + limits := [hg_limiter:turnover_limit_value()] | undefined +}. + +-spec get_explanation(st(), hg_invoice_payment:opts()) -> explanation(). +get_explanation( + #st{ + payment = Payment, + routes = Routes, + candidate_routes = CandidateRoutes, + route_scores = RouteScores, + route_limits = RouteLimits + }, + Opts +) -> + case Routes of + [] -> + %% If there's no routes even tried, then no explanation can be provided + throw(#payproc_RouteNotChosen{}); + [Route | AttemptedRoutes] -> + CandidateRoutesWithoutChosenRoute = exclude_chosen_route_from_candidates(CandidateRoutes, Route), + ChosenRWC = make_route_with_context(Route, RouteScores, RouteLimits), + AttemptedExplanation = maybe_explain_attempted_routes( + AttemptedRoutes, RouteScores, RouteLimits + ), + CandidatesExplanation = maybe_explain_candidate_routes( + CandidateRoutesWithoutChosenRoute, RouteScores, RouteLimits, ChosenRWC + ), + + Varset = gather_varset(Payment, Opts), + #payproc_InvoicePaymentExplanation{ + explained_routes = lists:flatten([ + route_explanation(chosen, ChosenRWC, ChosenRWC), + AttemptedExplanation, + CandidatesExplanation + ]), + used_varset = Varset + } + end. + +exclude_chosen_route_from_candidates(CandidateRoutes, Route) when is_list(CandidateRoutes) -> + CandidateRoutes -- [Route]; +exclude_chosen_route_from_candidates(_UndefinedCandidates, _Route) -> + []. + +-spec make_route_with_context(route(), scores(), limits()) -> route_with_context(). +make_route_with_context(Route, RouteScores, RouteLimits) -> + #{ + route => Route, + scores => hg_maybe:apply(fun(A) -> maps:get(Route, A, undefined) end, RouteScores), + limits => hg_maybe:apply(fun(A) -> maps:get(Route, A, undefined) end, RouteLimits) + }. + +maybe_explain_attempted_routes([], _RouteScores, _RouteLimits) -> + []; +maybe_explain_attempted_routes([AttemptedRoute | AttemptedRoutes], RouteScores, RouteLimits) -> + RouteWithContext = make_route_with_context(AttemptedRoute, RouteScores, RouteLimits), + [ + route_explanation(attempted, RouteWithContext, RouteWithContext) + | maybe_explain_attempted_routes(AttemptedRoutes, RouteScores, RouteLimits) + ]. + +maybe_explain_candidate_routes([], _RouteScores, _RouteLimits, _ChosenRWC) -> + []; +maybe_explain_candidate_routes([CandidateRoute | CandidateRoutes], RouteScores, RouteLimits, ChosenRWC) -> + RouteWithContext = make_route_with_context(CandidateRoute, RouteScores, RouteLimits), + [ + route_explanation(candidate, RouteWithContext, ChosenRWC) + | maybe_explain_candidate_routes(CandidateRoutes, RouteScores, RouteLimits, ChosenRWC) + ]. + +route_explanation(chosen, RouteWithContext, _ChosenRoute) -> + #{ + route := Route, + scores := Scores, + limits := Limits + } = RouteWithContext, + #payproc_InvoicePaymentRouteExplanation{ + route = Route, + is_chosen = true, + scores = Scores, + limits = Limits, + rejection_description = <<"This route was chosen.">> + }; +route_explanation(attempted, RouteWithContext, _ChosenRoute) -> + #{ + route := Route, + scores := Scores, + limits := Limits + } = RouteWithContext, + #payproc_InvoicePaymentRouteExplanation{ + route = Route, + is_chosen = false, + scores = Scores, + limits = Limits, + rejection_description = <<"This route was attempted, but wasn't succesfull.">> + }; +route_explanation(candidate, RouteWithContext, ChosenRoute) -> + #{ + route := Route, + scores := Scores, + limits := Limits + } = RouteWithContext, + #payproc_InvoicePaymentRouteExplanation{ + route = Route, + is_chosen = false, + scores = Scores, + limits = Limits, + rejection_description = candidate_rejection_explanation(RouteWithContext, ChosenRoute) + }. + +candidate_rejection_explanation( + #{scores := RouteScores, limits := RouteLimits}, + #{scores := ChosenScores} +) when RouteScores =:= undefined; RouteLimits =:= undefined; ChosenScores =:= undefined -> + <<"Not enough information to make judgement. Payment was done before relevant changes were done.">>; +candidate_rejection_explanation( + #{scores := RouteScores, limits := RouteLimits}, + #{scores := ChosenScores} +) when RouteScores =:= ChosenScores -> + IfEmpty = <<"This route has the same score as the chosen route, but wasn't chosen due to order in ruleset.">>, + check_route_limits(RouteLimits, IfEmpty); +candidate_rejection_explanation( + #{scores := RouteScores, limits := RouteLimits}, + #{scores := ChosenScores} +) when RouteScores > ChosenScores -> + IfEmpty = <<"No explanation for rejection can be found. Check in with developer.">>, + check_route_limits(RouteLimits, IfEmpty); +candidate_rejection_explanation( + #{scores := RouteScores, limits := RouteLimits}, + #{scores := ChosenScores} +) when RouteScores < ChosenScores -> + Explanation0 = check_route_scores(RouteScores, ChosenScores), + Explanation1 = check_route_limits(RouteLimits, <<"">>), + genlib_string:join(<<" ">>, [Explanation0, Explanation1]). + +check_route_limits(RouteLimits, IfEmpty) -> + case check_route_limits(RouteLimits) of + [] -> + IfEmpty; + Result -> + genlib_string:join(<<" ">>, Result) + end. + +check_route_limits([]) -> + []; +check_route_limits([TurnoverLimitValue | Rest]) -> + case TurnoverLimitValue of + #payproc_TurnoverLimitValue{ + limit = #domain_TurnoverLimit{ + id = LimitID, + upper_boundary = UpperBoundary + }, + value = Value + } when Value > UpperBoundary -> + [ + format( + "Limit with id ~p was exceeded with upper_boundary being ~p and limit value being ~p.", + [LimitID, UpperBoundary, Value] + ) + | check_route_limits(Rest) + ]; + _ -> + check_route_limits(Rest) + end. + +check_route_scores( + #domain_PaymentRouteScores{ + availability_condition = 0, + availability = Av + }, + _ChoseScores +) -> + AvailabilityConfig = maps:get(availability, genlib_app:env(hellgate, fault_detector, #{}), #{}), + CriticalFailRate = maps:get(critical_fail_rate, AvailabilityConfig, 0.7), + format( + "Availability reached critical level with availability of ~p, while threshold is ~p.", + [1.0 - Av, CriticalFailRate] + ); +check_route_scores( + #domain_PaymentRouteScores{ + conversion_condition = 0, + conversion = Cv + }, + _ChoseScores +) -> + ConversionConfig = maps:get(conversion, genlib_app:env(hellgate, fault_detector, #{}), #{}), + CriticalFailRate = maps:get(critical_fail_rate, ConversionConfig, 0.7), + format( + "Conversion reached critical level with conversion of ~p, while threshold is ~p.", + [1.0 - Cv, CriticalFailRate] + ); +check_route_scores( + #domain_PaymentRouteScores{ + terminal_priority_rating = Rating0 + }, + #domain_PaymentRouteScores{ + terminal_priority_rating = Rating1 + } +) when Rating0 < Rating1 -> + format("Priority of this route was less chosen one, where ~p < ~p.", [Rating0, Rating1]); +check_route_scores( + #domain_PaymentRouteScores{ + route_pin = Pin0 + }, + #domain_PaymentRouteScores{ + route_pin = Pin1 + } +) when Pin0 < Pin1 -> + format("Pin wasn't the same as in chosen route ~p < ~p.", [Pin0, Pin1]); +check_route_scores( + #domain_PaymentRouteScores{ + random_condition = Random0 + }, + #domain_PaymentRouteScores{ + random_condition = Random1 + } +) when Random0 < Random1 -> + format("Random condition wasn't the same as in chosen route ~p < ~p.", [Random0, Random1]); +check_route_scores( + #domain_PaymentRouteScores{ + availability = Av0 + }, + #domain_PaymentRouteScores{ + availability = Av1 + } +) when Av0 < Av1 -> + format("Avaliability is less than in chosen route ~p < ~p.", [Av0, Av1]); +check_route_scores( + #domain_PaymentRouteScores{ + conversion = Cv0 + }, + #domain_PaymentRouteScores{ + conversion = Cv1 + } +) when Cv0 < Cv1 -> + format("Conversion is less than in chosen route ~p < ~p.", [Cv0, Cv1]). + +gather_varset(Payment, Opts) -> + #domain_InvoicePayment{ + cost = Cost, + payer = Payer + } = Payment, + #domain_Party{ + id = PartyID + } = get_party(Opts), + #domain_Shop{ + id = ShopID, + category = Category + } = get_shop(Opts), + #payproc_Varset{ + category = Category, + currency = Cost#domain_Cash.currency, + amount = Cost, + shop_id = ShopID, + payment_tool = hg_invoice_payment:get_payer_payment_tool(Payer), + party_id = PartyID + }. + +get_party(#{party := Party}) -> + Party. + +get_shop(#{party := Party, invoice := Invoice}) -> + #domain_Invoice{shop_id = ShopID} = Invoice, + hg_party:get_shop(ShopID, Party). + +format(Format, Data) -> + erlang:iolist_to_binary(io_lib:format(Format, Data)). diff --git a/apps/routing/src/routing.app.src b/apps/routing/src/routing.app.src new file mode 100644 index 0000000..2ec68f7 --- /dev/null +++ b/apps/routing/src/routing.app.src @@ -0,0 +1,11 @@ +{application, routing, [ + {description, "Processing protocol definitions"}, + {vsn, "0"}, + {registered, []}, + {applications, [ + kernel, + stdlib, + thrift, + damsel + ]} +]}. diff --git a/rebar.lock b/rebar.lock index 4e056c1..a91d224 100644 --- a/rebar.lock +++ b/rebar.lock @@ -21,7 +21,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"23211ff8c0c45699fa6d78afa55f304e95dbee87"}}, + {ref,"decfa45d7ce4b3c948957c6ddba34742aaa9fdc5"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git",