diff --git a/apps/hellgate/include/hg_invoice_payment.hrl b/apps/hellgate/include/hg_invoice_payment.hrl index 78eb97a7..0997d164 100644 --- a/apps/hellgate/include/hg_invoice_payment.hrl +++ b/apps/hellgate/include/hg_invoice_payment.hrl @@ -32,7 +32,8 @@ allocation :: undefined | hg_allocation:allocation(), route_limits = #{} :: hg_routing:limits(), route_scores = #{} :: hg_routing:scores(), - shop_limit_status = undefined :: undefined | initialized | finalized + shop_limit_status = undefined :: undefined | initialized | finalized, + exchange_context :: undefined | hg_invoice_payment:exchange_context() }). -record(refund_st, { diff --git a/apps/hellgate/include/payment_events.hrl b/apps/hellgate/include/payment_events.hrl index 5c0962b0..180548e9 100644 --- a/apps/hellgate/include/payment_events.hrl +++ b/apps/hellgate/include/payment_events.hrl @@ -76,6 +76,12 @@ } ). +-define(invoice_payment_exchange_context_changed(ExchangeContext), + {invoice_payment_exchange_context_changed, #payproc_InvoicePaymentExchangeContextChanged{ + exchange_context = ExchangeContext + }} +). + -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 d5b33e3e..fb17556b 100644 --- a/apps/hellgate/src/hellgate.app.src +++ b/apps/hellgate/src/hellgate.app.src @@ -12,6 +12,7 @@ progressor, hg_progressor, hg_proto, + exrates_proto, routing, cowboy, prometheus, diff --git a/apps/hellgate/src/hg_accounting.erl b/apps/hellgate/src/hg_accounting.erl index 189db6ad..94a0f00f 100644 --- a/apps/hellgate/src/hg_accounting.erl +++ b/apps/hellgate/src/hg_accounting.erl @@ -231,7 +231,8 @@ collect_postings(Cashflow) -> to_id = Destination, amount = Amount, currency_sym_code = CurrencyCode, - description = construct_posting_description(Details) + description = construct_posting_description(Details), + exchange_context = ExchangeContext } || #domain_FinalCashFlowPosting{ source = #domain_FinalCashFlowAccount{account_id = Source}, @@ -240,7 +241,8 @@ collect_postings(Cashflow) -> volume = #domain_Cash{ amount = Amount, currency = #domain_CurrencyRef{symbolic_code = CurrencyCode} - } + }, + exchange_context = ExchangeContext } <- Cashflow ]. diff --git a/apps/hellgate/src/hg_cashflow.erl b/apps/hellgate/src/hg_cashflow.erl index d5f8631b..3cbfd39b 100644 --- a/apps/hellgate/src/hg_cashflow.erl +++ b/apps/hellgate/src/hg_cashflow.erl @@ -12,6 +12,8 @@ -include_lib("damsel/include/dmsl_domain_thrift.hrl"). -export_type([final_cash_flow/0]). +-export_type([cash_flow/0]). +-export_type([cash_volume/0]). -type account() :: dmsl_domain_thrift:'CashFlowAccount'(). -type account_id() :: dmsl_domain_thrift:'AccountID'(). @@ -30,10 +32,14 @@ -type shop_config_ref() :: dmsl_domain_thrift:'ShopConfigRef'(). -type party_config_ref() :: dmsl_domain_thrift:'PartyConfigRef'(). -type route() :: hg_route:payment_route(). +-type options() :: #{ + exchange_context => hg_invoice_payment:exchange_context() +}. %% -export([finalize/3]). +-export([finalize/4]). -export([revert/1]). -export([compute_volume/2]). @@ -56,18 +62,32 @@ details = Details }). +-define(final_posting(Source, Destination, Volume, Details, ExchangeContext), #domain_FinalCashFlowPosting{ + source = Source, + destination = Destination, + volume = Volume, + details = Details, + exchange_context = ExchangeContext +}). + -spec finalize(cash_flow(), context(), account_map()) -> final_cash_flow() | no_return(). finalize(CF, Context, AccountMap) -> - compute_postings(CF, Context, AccountMap). + finalize(CF, Context, AccountMap, #{}). + +-spec finalize(cash_flow(), context(), account_map(), options()) -> final_cash_flow() | no_return(). +finalize(CF, Context, AccountMap, Opts) -> + compute_postings(CF, Context, AccountMap, Opts). --spec compute_postings(cash_flow(), context(), account_map()) -> final_cash_flow() | no_return(). -compute_postings(CF, Context, AccountMap) -> +-spec compute_postings(cash_flow(), context(), account_map(), options()) -> final_cash_flow() | no_return(). +compute_postings(CF, Context, AccountMap, Opts) -> + ExchangeContext = maps:get(exchange_context, Opts, undefined), [ ?final_posting( construct_final_account(Source, AccountMap), construct_final_account(Destination, AccountMap), - compute_volume(Volume, Context), - Details + hg_currency_converter:maybe_reverse_convert_cash(ExchangeContext, compute_volume(Volume, Context)), + Details, + ExchangeContext ) || ?posting(Source, Destination, Volume, Details) <- CF ]. @@ -125,8 +145,8 @@ resolve_account(AccountType, AccountMap) -> -spec revert(final_cash_flow()) -> final_cash_flow(). revert(CF) -> [ - ?final_posting(Destination, Source, Volume, revert_details(Details)) - || ?final_posting(Source, Destination, Volume, Details) <- CF + ?final_posting(Destination, Source, Volume, revert_details(Details), ExchangeContext) + || ?final_posting(Source, Destination, Volume, Details, ExchangeContext) <- CF ]. revert_details(undefined) -> diff --git a/apps/hellgate/src/hg_cashflow_utils.erl b/apps/hellgate/src/hg_cashflow_utils.erl index 387f25e4..8e5414bd 100644 --- a/apps/hellgate/src/hg_cashflow_utils.erl +++ b/apps/hellgate/src/hg_cashflow_utils.erl @@ -20,7 +20,8 @@ revision := revision(), merchant_terms => dmsl_domain_thrift:'PaymentsServiceTerms'(), refund => refund(), - allocation => hg_allocation:allocation() + allocation => hg_allocation:allocation(), + exchange_context => hg_invoice_payment:exchange_context() | undefined }. -export_type([cash_flow_context/0]). @@ -117,11 +118,15 @@ construct_transaction_cashflow( construct_provider_cashflow(PaymentInstitution, #{provision_terms := ProvisionTerms} = Context) -> ProviderCashflowSelector = get_provider_cashflow_selector(ProvisionTerms), ProviderCashflow = get_selector_value(provider_payment_cash_flow, ProviderCashflowSelector), + Opts = maps:with([exchange_context], Context), AccountMap = hg_accounting:collect_account_map(make_collect_account_context(PaymentInstitution, Context)), - construct_final_cashflow(ProviderCashflow, #{operation_amount => get_amount(Context)}, AccountMap). + construct_final_cashflow(ProviderCashflow, #{operation_amount => get_amount(Context)}, AccountMap, Opts). construct_final_cashflow(Cashflow, Context, AccountMap) -> - hg_cashflow:finalize(Cashflow, Context, AccountMap). + construct_final_cashflow(Cashflow, Context, AccountMap, #{}). + +construct_final_cashflow(Cashflow, Context, AccountMap, Opts) -> + hg_cashflow:finalize(Cashflow, Context, AccountMap, Opts). get_cashflow_payment_institution( #domain_ShopConfig{payment_institution = PaymentInstitutionRef}, diff --git a/apps/hellgate/src/hg_currency_converter.erl b/apps/hellgate/src/hg_currency_converter.erl new file mode 100644 index 00000000..577cdf14 --- /dev/null +++ b/apps/hellgate/src/hg_currency_converter.erl @@ -0,0 +1,75 @@ +-module(hg_currency_converter). + +-include_lib("hellgate/include/domain.hrl"). + +-export([convert_cash/2]). +-export([reverse_convert_cash/2]). +-export([maybe_convert_cash/2]). +-export([maybe_reverse_convert_cash/2]). + +-export_type([exchange_context/0]). + +-type cash() :: dmsl_domain_thrift:'Cash'(). +-type exchange_context() :: dmsl_domain_thrift:'ExchangeContext'(). + +-spec convert_cash(exchange_context(), cash()) -> cash(). +convert_cash(ExchangeContext, Cash) -> + do_convert_cash(ExchangeContext, Cash, forward). + +-spec reverse_convert_cash(exchange_context(), cash()) -> cash(). +reverse_convert_cash(ExchangeContext, Cash) -> + %% We do not use two-way exchange rates for currency pairs. + do_convert_cash(ExchangeContext, Cash, reverse). + +-spec maybe_convert_cash(exchange_context() | undefined, cash()) -> cash(). +maybe_convert_cash(undefined, Cash) -> + Cash; +maybe_convert_cash(ExchangeContext, Cash) -> + convert_cash(ExchangeContext, Cash). + +-spec maybe_reverse_convert_cash(exchange_context() | undefined, cash()) -> cash(). +maybe_reverse_convert_cash(undefined, Cash) -> + Cash; +maybe_reverse_convert_cash(ExchangeContext, Cash) -> + reverse_convert_cash(ExchangeContext, Cash). + +do_convert_cash( + #domain_ExchangeContext{ + exchange_rate = ExchangeRate, + source_currency = SourceCurrency, + destination_currency = DestinationCurrency + }, + Cash, + Direction +) -> + {InputCurrency, OutputCurrency, SkipCurrency} = + case Direction of + forward -> + {SourceCurrency, DestinationCurrency, DestinationCurrency}; + reverse -> + {DestinationCurrency, SourceCurrency, SourceCurrency} + end, + case Cash of + #domain_Cash{currency = #domain_CurrencyRef{symbolic_code = SkipCurrency}} -> + Cash; + #domain_Cash{ + amount = Amount, + currency = #domain_CurrencyRef{symbolic_code = InputCurrency} + } -> + convert_amount(Amount, ExchangeRate, OutputCurrency, Direction) + end. + +convert_amount(Amount, ExchangeRate, OutputCurrency, Direction) -> + #base_Rational{p = P, q = Q} = ExchangeRate, + RateRational = genlib_rational:new(P, Q), + AmountRational = genlib_rational:new(Amount), + ConvertedAmountRational = + case Direction of + forward -> + genlib_rational:dvd(AmountRational, RateRational); + reverse -> + genlib_rational:mul(AmountRational, RateRational) + end, + Rounding = application:get_env(hellgate, exchange_rounding_method, round_half_away_from_zero), + ConvertedAmount = genlib_rational:round(ConvertedAmountRational, Rounding), + #domain_Cash{amount = ConvertedAmount, currency = #domain_CurrencyRef{symbolic_code = OutputCurrency}}. diff --git a/apps/hellgate/src/hg_exrates.erl b/apps/hellgate/src/hg_exrates.erl new file mode 100644 index 00000000..8088064e --- /dev/null +++ b/apps/hellgate/src/hg_exrates.erl @@ -0,0 +1,44 @@ +-module(hg_exrates). + +-include_lib("exrates_proto/include/exrates_service_thrift.hrl"). +-include_lib("exrates_proto/include/exrates_base_thrift.hrl"). + +-export([get_exchange_rate/2]). + +-type currency_symbolic_code() :: binary(). +-type rate() :: #{ + p := integer(), + q := integer() +}. + +-define(RATES_SERVICE, rate_boss). + +-spec get_exchange_rate(currency_symbolic_code(), currency_symbolic_code()) -> + {ok, rate()} | {error, _Reason}. +get_exchange_rate(SourceCurrency, DestinationCurrency) -> + Args = #'service_GetCurrencyExchangeRateRequest'{ + currency_data = #'service_CurrencyData'{ + source_currency = SourceCurrency, + destination_currency = DestinationCurrency + } + }, + case issue_call('GetExchangeRateData', {Args}) of + {ok, #'service_GetCurrencyExchangeRateResult'{ + exchange_rate = #base_Rational{p = P, q = Q} + }} -> + {ok, #{p => P, q => Q}}; + {exception, #'service_ExRateNotFound'{}} -> + {error, not_found}; + {error, _} -> + {error, unexpected_error} + end. + +issue_call(Func, Args) -> + try hg_woody_wrapper:call(?RATES_SERVICE, Func, Args) of + Result -> + Result + catch + error:{woody_error, _ErrorType} = Reason:_St -> + logger:error("exchange rates error: ~p", [Reason]), + {error, Reason} + end. diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 493c5d6d..210de80d 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -57,6 +57,7 @@ -export([get_invoice/1]). -export([get_origin/1]). -export([get_risk_score/1]). +-export([get_exchange_context/1]). -export([construct_payment_info/2]). -export([set_repair_scenario/2]). @@ -129,6 +130,7 @@ -export_type([change_opts/0]). -export_type([action/0]). -export_type([cashflow_context/0]). +-export_type([exchange_context/0]). -type activity() :: payment_activity() @@ -232,9 +234,12 @@ varset := hg_varset:varset(), revision := hg_domain:revision(), merchant_terms => dmsl_domain_thrift:'PaymentsServiceTerms'(), - allocation => hg_allocation:allocation() | undefined + allocation => hg_allocation:allocation() | undefined, + exchange_context => exchange_context() }. +-type exchange_context() :: hg_currency_converter:exchange_context(). + %% -include("domain.hrl"). @@ -273,6 +278,10 @@ get_candidate_routes(#st{candidate_routes = undefined}) -> get_candidate_routes(#st{candidate_routes = Routes}) -> Routes. +-spec get_exchange_context(st()) -> exchange_context() | undefined. +get_exchange_context(#st{exchange_context = ExchangeContext}) -> + ExchangeContext. + -spec get_adjustments(st()) -> [adjustment()]. get_adjustments(#st{adjustments = As}) -> As. @@ -997,7 +1006,8 @@ partial_capture(St0, Reason, Cost, Cart, Opts, MerchantTerms, Timestamp, Allocat Route = get_route(St), ProviderTerms = hg_party:get_route_payment_terms(Route, VS, Revision), ok = validate_provider_holds_terms(ProviderTerms), - Context = #{ + ExchangeContext = get_exchange_context(St), + Context = genlib_map:compact(#{ provision_terms => ProviderTerms, merchant_terms => MerchantTerms, route => Route, @@ -1005,8 +1015,9 @@ partial_capture(St0, Reason, Cost, Cart, Opts, MerchantTerms, Timestamp, Allocat timestamp => Timestamp, varset => VS, revision => Revision, - allocation => Allocation - }, + allocation => Allocation, + exchange_context => ExchangeContext + }), FinalCashflow = calculate_cashflow(Context, Opts), Changes = start_partial_capture(Reason, Cost, Cart, FinalCashflow, Allocation), {ok, {Changes, hg_machine_action:instant()}}. @@ -1208,6 +1219,7 @@ make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, Tim Route = get_route(St), ProviderPaymentsTerms = get_provider_terminal_terms(Route, VS, Revision), Allocation = Refund#domain_InvoicePaymentRefund.allocation, + ExchangeContext = get_exchange_context(St), CollectCashflowContext = genlib_map:compact(#{ operation => refund, provision_terms => get_provider_refunds_terms(ProviderPaymentsTerms, Refund, Payment), @@ -1221,7 +1233,8 @@ make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, Tim varset => VS, revision => Revision, refund => Refund, - allocation => Allocation + allocation => Allocation, + exchange_context => ExchangeContext }), hg_cashflow_utils:collect_cashflow(CollectCashflowContext). @@ -1406,15 +1419,17 @@ create_cash_flow_adjustment(Timestamp, Params, DomainRevision, St, Opts) -> {Payment1, AdditionalEvents} = maybe_inject_new_cost_amount( Payment, Params#payproc_InvoicePaymentAdjustmentParams.scenario ), - Context = #{ + ExchangeContext = get_exchange_context(St), + Context = genlib_map:compact(#{ provision_terms => get_provider_terminal_terms(Route, VS, NewRevision), route => Route, payment => Payment1, timestamp => Timestamp, varset => VS, revision => NewRevision, - allocation => Allocation - }, + allocation => Allocation, + exchange_context => ExchangeContext + }), NewCashFlow = case Payment of #domain_InvoicePayment{status = {failed, _}} -> @@ -1558,15 +1573,17 @@ get_cash_flow_for_target_status({captured, Captured}, St0, Opts) -> St = St0#st{payment = Payment2}, Revision = Payment2#domain_InvoicePayment.domain_revision, VS = collect_validation_varset(St, Opts), - Context = #{ + ExchangeContext = get_exchange_context(St), + Context = genlib_map:compact(#{ provision_terms => get_provider_terminal_terms(Route, VS, Revision), route => Route, payment => Payment2, timestamp => Timestamp, varset => VS, revision => Revision, - allocation => Allocation - }, + allocation => Allocation, + exchange_context => ExchangeContext + }), calculate_cashflow(Context, Opts); get_cash_flow_for_target_status({cancelled, _}, _St, _Opts) -> []; @@ -1803,11 +1820,12 @@ process_refund(ID, #st{opts = Options0, payment = Payment, repair_scenario = Sce RepairAction -> RepairAction end, PaymentInfo = construct_payment_info(St, get_opts(St)), - Options1 = Options0#{ + Options1 = genlib_map:compact(Options0#{ payment => Payment, payment_info => PaymentInfo, - repair_scenario => RepairScenario - }, + repair_scenario => RepairScenario, + exchange_context => get_exchange_context(St) + }), Refund = try_get_refund_state(ID, St), {Step, {Events0, Action}} = hg_invoice_payment_refund:process(Options1, Refund), Events1 = hg_invoice_payment_refund:wrap_events(Events0, Refund), @@ -1841,8 +1859,8 @@ process_refund_result(Changes, Refund0, St) -> repair_process_timeout(Activity, Action, #st{repair_scenario = Scenario} = St) -> case hg_invoice_repair:check_for_action(fail_pre_processing, Scenario) of {result, Result} when - Activity =:= {payment, routing} orelse - Activity =:= {payment, cash_flow_building} + Activity =:= {payment, routing}; + Activity =:= {payment, cash_flow_building} -> rollback_broken_payment_limits(St), Result; @@ -2010,13 +2028,20 @@ produce_routing_events(#{error := Error} = Ctx, Revision, St) when Error =/= und [?route_changed(Route, Candidates, RouteScores, RouteLimits, Decision), ?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)), + ChoosenRoute = hg_routing_ctx:choosen_route(Ctx), + Route = hg_route:to_payment_route(ChoosenRoute), Candidates = ordsets:from_list([hg_route:to_payment_route(R) || R <- hg_routing_ctx:considered_candidates(Ctx)]), RouteScores = hg_routing_ctx:route_scores(Ctx), RouteLimits = hg_routing_ctx:route_limits(Ctx), Decision = build_route_decision_context(Route, Revision), - [?route_changed(Route, Candidates, RouteScores, RouteLimits, Decision)]. + [?route_changed(Route, Candidates, RouteScores, RouteLimits, Decision)] ++ + maybe_exchange_context_changed(ChoosenRoute). + +maybe_exchange_context_changed(#{exchange_context := ExchangeContext}) -> + [?invoice_payment_exchange_context_changed(ExchangeContext)]; +maybe_exchange_context_changed(_) -> + []. build_route_decision_context(Route, Revision) -> ProvisionTerms = hg_party:get_route_provision_terms(Route, #{}, Revision), @@ -2166,15 +2191,17 @@ process_cash_flow_building(Action, St) -> VS1 = collect_validation_varset(get_party_config_ref(Opts), get_shop_obj(Opts, Revision), Payment, VS0), ProviderTerms = get_provider_terminal_terms(Route, VS1, Revision), Allocation = get_allocation(St), - Context = #{ + ExchangeContext = get_exchange_context(St), + Context = genlib_map:compact(#{ provision_terms => ProviderTerms, route => Route, payment => Payment, timestamp => Timestamp, varset => VS1, revision => Revision, - allocation => Allocation - }, + allocation => Allocation, + exchange_context => ExchangeContext + }), FinalCashflow = calculate_cashflow(Context, Opts), _ = rollback_unused_payment_limits(St), _Clock = hg_accounting:hold( @@ -2264,7 +2291,12 @@ process_accounter_update(Action, #st{partial_cash_flow = FinalCashflow, capture_ handle_callback({refund, ID}, Payload, _Session0, St) -> PaymentInfo = construct_payment_info(St, get_opts(St)), Refund = try_get_refund_state(ID, St), - {Resp, {Step, {Events0, Action}}} = hg_invoice_payment_refund:process_callback(Payload, PaymentInfo, Refund), + Options = genlib_map:compact(#{ + exchange_context => get_exchange_context(St) + }), + {Resp, {Step, {Events0, Action}}} = hg_invoice_payment_refund:process_callback( + Payload, PaymentInfo, Refund, Options + ), Events1 = hg_invoice_payment_refund:wrap_events(Events0, Refund), {Resp, {Step, {Events1, Action}}}; handle_callback(Activity, Payload, Session0, St) -> @@ -2377,15 +2409,17 @@ process_result({payment, processing_accounter}, Action, #st{new_cash = Cost} = S MerchantTerms = get_merchant_payments_terms(Opts, Revision, Timestamp, VS), Route = get_route(St1), ProviderTerms = hg_party:get_route_payment_terms(Route, VS, Revision), - Context = #{ + ExchangeContext = get_exchange_context(St1), + Context = genlib_map:compact(#{ provision_terms => ProviderTerms, merchant_terms => MerchantTerms, route => Route, payment => Payment1, timestamp => Timestamp, varset => VS, - revision => Revision - }, + revision => Revision, + exchange_context => ExchangeContext + }), FinalCashflow = calculate_cashflow(Context, Opts), %% Hold limits (only for chosen route) for new cashflow {_PaymentInstitution, RouteVS, _Revision} = route_args(St1), @@ -2969,7 +3003,7 @@ construct_payment_info(St, Opts) -> St, #proxy_provider_PaymentInfo{ shop = construct_proxy_shop(get_shop_obj(Opts, Revision)), - invoice = construct_proxy_invoice(get_invoice(Opts)), + invoice = construct_proxy_invoice(get_invoice(Opts), St), payment = construct_proxy_payment(Payment, get_trx(St), St) } ). @@ -2978,12 +3012,14 @@ construct_payment_info(idle, _Target, _St, PaymentInfo) -> PaymentInfo; construct_payment_info( {payment, _Step}, - Target = ?captured(), - _St, + _Target = ?captured(_, Cost), + St, PaymentInfo ) -> + ExchangeContext = get_exchange_context(St), + ConvertedCost = hg_currency_converter:maybe_convert_cash(ExchangeContext, Cost), PaymentInfo#proxy_provider_PaymentInfo{ - capture = construct_proxy_capture(Target) + capture = construct_proxy_capture(ConvertedCost) }; construct_payment_info({payment, _Step}, _Target, _St, PaymentInfo) -> PaymentInfo; @@ -2997,7 +3033,7 @@ construct_proxy_payment( domain_revision = Revision, payer = Payer, payer_session_info = PayerSessionInfo, - cost = Cost, + cost = Cost0, make_recurrent = MakeRecurrent, skip_recurrent = SkipRecurrent, processing_deadline = Deadline @@ -3007,6 +3043,13 @@ construct_proxy_payment( ) -> ContactInfo = get_contact_info(Payer), PaymentTool = get_payer_payment_tool(Payer), + {Cost1, OriginalCost} = + case get_exchange_context(St) of + undefined -> + {Cost0, undefined}; + ExchangeContext -> + {hg_currency_converter:convert_cash(ExchangeContext, Cost0), Cost0} + end, #proxy_provider_InvoicePayment{ id = ID, created_at = CreatedAt, @@ -3014,7 +3057,8 @@ construct_proxy_payment( payment_resource = construct_payment_resource(Payer, St), payment_service = hg_payment_tool:get_payment_service(PaymentTool, Revision), payer_session_info = PayerSessionInfo, - cost = construct_proxy_cash(Cost), + cost = construct_proxy_cash(Cost1), + original_cost = maybe_construct_proxy_cash(OriginalCost), contact_info = ContactInfo, make_recurrent = MakeRecurrent, skip_recurrent = SkipRecurrent, @@ -3057,7 +3101,8 @@ construct_proxy_invoice( due = Due, details = Details, cost = Cost - } + }, + _St ) -> #proxy_provider_Invoice{ id = InvoiceID, @@ -3085,6 +3130,11 @@ construct_proxy_shop( location = Location }. +maybe_construct_proxy_cash(undefined) -> + undefined; +maybe_construct_proxy_cash(Cash) -> + construct_proxy_cash(Cash). + construct_proxy_cash(#domain_Cash{ amount = Amount, currency = CurrencyRef @@ -3094,7 +3144,7 @@ construct_proxy_cash(#domain_Cash{ currency = hg_domain:get({currency, CurrencyRef}) }. -construct_proxy_capture(?captured(_, Cost)) -> +construct_proxy_capture(Cost) -> #proxy_provider_InvoicePaymentCapture{ cost = construct_proxy_cash(Cost) }. @@ -3276,6 +3326,8 @@ merge_change( cash_flow = undefined, %% `trx` from previous session (if any) also must be considered obsolete. trx = undefined, + %% exchange_context from previous route must be considered obsolete + exchange_context = undefined, routes = [Route | Routes], candidate_routes = ordsets:to_list(Candidates), activity = {payment, cash_flow_building}, @@ -3283,6 +3335,15 @@ merge_change( route_limits = hg_maybe:apply(fun(L) -> maps:merge(RouteLimits, L) end, Limits, RouteLimits), payment = Payment1 }; +merge_change( + Change = ?invoice_payment_exchange_context_changed(ExchangeContext), + #st{} = St, + Opts +) -> + _ = validate_transition([{payment, cash_flow_building}], Change, St, Opts), + St#st{ + exchange_context = ExchangeContext + }; merge_change(Change = ?payment_capture_started(Data), #st{} = St, Opts) -> _ = validate_transition([{payment, S} || S <- [flow_waiting]], Change, St, Opts), St#st{ @@ -3336,8 +3397,10 @@ merge_change(Change = ?cash_changed(_OldCash, NewCash), #st{} = St, Opts) -> Opts ), Payment0 = get_payment(St), - Payment1 = Payment0#domain_InvoicePayment{changed_cost = NewCash}, - St#st{new_cash = NewCash, new_cash_provided = true, payment = Payment1}; + ExchangeContext = get_exchange_context(St), + ReConvertedNewCash = hg_currency_converter:maybe_reverse_convert_cash(ExchangeContext, NewCash), + Payment1 = Payment0#domain_InvoicePayment{changed_cost = ReConvertedNewCash}, + St#st{new_cash = ReConvertedNewCash, new_cash_provided = true, payment = Payment1}; merge_change(Change = ?payment_rollback_started(Failure), St, Opts) -> _ = validate_transition( [ diff --git a/apps/hellgate/src/hg_invoice_payment_chargeback.erl b/apps/hellgate/src/hg_invoice_payment_chargeback.erl index 80adbf8e..0dc1418e 100644 --- a/apps/hellgate/src/hg_invoice_payment_chargeback.erl +++ b/apps/hellgate/src/hg_invoice_payment_chargeback.erl @@ -420,6 +420,7 @@ build_chargeback_final_cash_flow(State, Opts) -> Body = get_body(State), Payment = get_opts_payment(Opts), Invoice = get_opts_invoice(Opts), + ExchangeContext = get_opts_exchange_context(Opts), Route = get_opts_route(Opts), Party = get_opts_party(Opts), PartyConfigRef = get_opts_party_config_ref(Opts), @@ -432,6 +433,7 @@ build_chargeback_final_cash_flow(State, Opts) -> ServiceCashFlow = get_chargeback_service_cash_flow(ServiceTerms), ProviderCashFlow = get_chargeback_provider_cash_flow(ProviderTerms), ProviderFees = collect_chargeback_provider_fees(ProviderTerms), + ProviderOpts = genlib_map:compact(#{exchange_context => ExchangeContext}), PaymentInstitutionRef = Shop#domain_ShopConfig.payment_institution, PaymentInst = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS, Revision), Provider = get_route_provider(Route, Revision), @@ -449,7 +451,7 @@ build_chargeback_final_cash_flow(State, Opts) -> ServiceContext = build_service_cash_flow_context(State), ProviderContext = build_provider_cash_flow_context(State, ProviderFees), ServiceFinalCF = hg_cashflow:finalize(ServiceCashFlow, ServiceContext, AccountMap), - ProviderFinalCF = hg_cashflow:finalize(ProviderCashFlow, ProviderContext, AccountMap), + ProviderFinalCF = hg_cashflow:finalize(ProviderCashFlow, ProviderContext, AccountMap, ProviderOpts), ServiceFinalCF ++ ProviderFinalCF. build_service_cash_flow_context(State) -> @@ -733,6 +735,9 @@ get_opts_payment_state(#{payment_state := PaymentState}) -> get_opts_payment(#{payment_state := PaymentState}) -> hg_invoice_payment:get_payment(PaymentState). +get_opts_exchange_context(#{payment_state := PaymentState}) -> + hg_invoice_payment:get_exchange_context(PaymentState). + get_opts_route(#{payment_state := PaymentState}) -> hg_invoice_payment:get_route(PaymentState). diff --git a/apps/hellgate/src/hg_invoice_payment_refund.erl b/apps/hellgate/src/hg_invoice_payment_refund.erl index 6c18a766..7b26b013 100644 --- a/apps/hellgate/src/hg_invoice_payment_refund.erl +++ b/apps/hellgate/src/hg_invoice_payment_refund.erl @@ -73,6 +73,7 @@ -export([process/2]). -export([process_callback/3]). +-export([process_callback/4]). -export([deduce_activity/1]). %% Internal types @@ -124,7 +125,8 @@ invoice_id := invoice_id(), payment_id := payment_id(), repair_scenario => repair_scenario(), - payment_info => payment_info() + payment_info => payment_info(), + exchange_context => hg_invoice_payment:exchange_context() }. -type options() :: #{ @@ -135,7 +137,9 @@ payment => payment(), repair_scenario => repair_scenario(), - payment_info => payment_info() + payment_info => payment_info(), + + exchange_context => hg_invoice_payment:exchange_context() }. -type repair_scenario() :: {result, proxy_result()}. @@ -224,6 +228,11 @@ process(Options, Refund0) -> -spec process_callback(callback(), payment_info(), t()) -> {callback_response(), machine_result()}. process_callback(Payload, PaymentInfo0, Refund) -> + process_callback(Payload, PaymentInfo0, Refund, #{}). + +-spec process_callback(callback(), payment_info(), t(), options()) -> {callback_response(), machine_result()}. +process_callback(Payload, PaymentInfo0, Refund0, Options) -> + Refund = Refund0#{injected_context => maps:with([exchange_context], Options)}, PaymentInfo1 = construct_payment_info(PaymentInfo0, Refund), Session0 = hg_session:set_payment_info(PaymentInfo1, session(Refund)), {Response, {Result, Session1}} = hg_session:process_callback(Payload, Session0), @@ -507,7 +516,8 @@ inject_context(Options, Refund) -> invoice_id => InvoiceID, payment_id => PaymentID, repair_scenario => maps:get(repair_scenario, Options, undefined), - payment_info => maps:get(payment_info, Options, undefined) + payment_info => maps:get(payment_info, Options, undefined), + exchange_context => maps:get(exchange_context, Options, undefined) }), Refund#{injected_context => Context}. @@ -520,6 +530,7 @@ get_injected_invoice_id(#{injected_context := #{invoice_id := V}}) -> V. get_injected_payment_id(#{injected_context := #{payment_id := V}}) -> V. get_injected_repair_scenario(#{injected_context := Context}) -> maps:get(repair_scenario, Context, undefined). get_injected_payment_info(#{injected_context := Context}) -> maps:get(payment_info, Context, undefined). +get_injected_exchange_context(#{injected_context := Context}) -> maps:get(exchange_context, Context, undefined). %% Event utils @@ -581,12 +592,14 @@ get_refund_created_at(#domain_InvoicePaymentRefund{created_at = CreatedAt}) -> CreatedAt. construct_payment_info(PaymentInfo, Refund) -> + ExchangeContext = get_injected_exchange_context(Refund), + ConvertedCash = hg_currency_converter:maybe_convert_cash(ExchangeContext, cash(Refund)), PaymentInfo#proxy_provider_PaymentInfo{ refund = #proxy_provider_InvoicePaymentRefund{ id = id(Refund), created_at = get_refund_created_at(refund(Refund)), trx = hg_session:trx_info(session(Refund)), - cash = construct_proxy_cash(cash(Refund)) + cash = construct_proxy_cash(ConvertedCash) } }. diff --git a/apps/hellgate/test/hg_ct_helper.erl b/apps/hellgate/test/hg_ct_helper.erl index 37e6b445..0bceec03 100644 --- a/apps/hellgate/test/hg_ct_helper.erl +++ b/apps/hellgate/test/hg_ct_helper.erl @@ -165,6 +165,7 @@ start_app(hg_proto = AppName) -> url => <<"http://limiter:8022/v1/limiter">>, transport_opts => #{} }, + rate_boss => <<"http://127.0.0.1:32022/test/exrates/dummy">>, customer_management => <<"http://cubasty:8022/v1/customer/management">>, bank_card_storage => <<"http://cubasty:8022/v1/customer/bank_card">> }} diff --git a/apps/hellgate/test/hg_dummy_exrates.erl b/apps/hellgate/test/hg_dummy_exrates.erl new file mode 100644 index 00000000..3200b2a9 --- /dev/null +++ b/apps/hellgate/test/hg_dummy_exrates.erl @@ -0,0 +1,66 @@ +-module(hg_dummy_exrates). + +-include_lib("exrates_proto/include/exrates_service_thrift.hrl"). +-include_lib("exrates_proto/include/exrates_base_thrift.hrl"). + +-behaviour(hg_woody_service_wrapper). + +-export([handle_function/3]). + +-behaviour(hg_test_proxy). + +-export([get_service_spec/0]). + +-spec get_service_spec() -> hg_proto:service_spec(). +get_service_spec() -> + {"/test/exrates/dummy", {exrates_service_thrift, 'ExchangeRateService'}}. + +-spec handle_function(woody:func(), woody:args(), hg_woody_service_wrapper:handler_opts()) -> term() | no_return(). +handle_function( + 'GetExchangeRateData', + {#service_GetCurrencyExchangeRateRequest{ + currency_data = + #service_CurrencyData{ + source_currency = <<"RUB">>, + destination_currency = <<"USD">> + } = CurrencyData + }}, + _ +) -> + Timestamp = calendar:system_time_to_rfc3339(erlang:system_time(second), [{offset, "Z"}]), + #service_GetCurrencyExchangeRateResult{ + currency_data = CurrencyData, + exchange_rate = #base_Rational{p = 80, q = 1}, + timestamp = unicode:characters_to_binary(Timestamp) + }; +handle_function( + 'GetExchangeRateData', + {#service_GetCurrencyExchangeRateRequest{ + currency_data = + #service_CurrencyData{ + source_currency = <<"RUB">>, + destination_currency = <<"EUR">> + } + }}, + _ +) -> + throw({exception, #service_ExRateNotFound{}}); +handle_function( + 'GetExchangeRateData', + {#service_GetCurrencyExchangeRateRequest{ + currency_data = + #service_CurrencyData{ + source_currency = <<"RUB">>, + destination_currency = <<"JPY">> + } + }}, + _ +) -> + timer:sleep(5100), + {exception, #service_ExRateNotFound{}}; +handle_function( + 'GetExchangeRateData', + _, + _ +) -> + erlang:error(internal_exchange_error). diff --git a/apps/hellgate/test/hg_dummy_provider.erl b/apps/hellgate/test/hg_dummy_provider.erl index 5725d5d9..badda0ce 100644 --- a/apps/hellgate/test/hg_dummy_provider.erl +++ b/apps/hellgate/test/hg_dummy_provider.erl @@ -189,6 +189,9 @@ process_payment(?processed(), undefined, PaymentInfo, CtxOpts, _) -> change_cash_decrease -> %% simple workflow without 3DS result(?sleep(0), <<"sleeping">>); + change_currency_and_increase -> + %% simple workflow without 3DS + result(?sleep(0), <<"sleeping">>); no_preauth -> %% simple workflow without 3DS maybe_fail(PaymentInfo, CtxOpts, result(?sleep(0), <<"sleeping">>)); @@ -257,6 +260,14 @@ process_payment(?processed(), <<"sleeping">>, PaymentInfo, CtxOpts, _) -> finish(success(PaymentInfo, get_payment_increased_cost(PaymentInfo)), mk_trx(TrxID, PaymentInfo)); change_cash_decrease -> finish(success(PaymentInfo, get_payment_decreased_cost(PaymentInfo)), mk_trx(TrxID, PaymentInfo)); + change_currency_and_increase -> + finish( + success( + PaymentInfo, + get_payment_increased_changed_currency(PaymentInfo) + ), + mk_trx(TrxID, PaymentInfo) + ); unexpected_failure -> error(unexpected_failure); {temporary_unavailability, Scenario} -> @@ -549,6 +560,23 @@ get_payment_decreased_cost(PaymentInfo) -> Cost = #proxy_provider_Cash{amount = Amount} = get_payment_cost(PaymentInfo), Cost#proxy_provider_Cash{amount = Amount div 2}. +get_payment_increased_changed_currency(PaymentInfo) -> + %% the cost was converted from RUB at the exchange rate {80, 1} + #proxy_provider_PaymentInfo{ + payment = #proxy_provider_InvoicePayment{ + cost = + #proxy_provider_Cash{ + amount = 525 = Amount, + currency = #domain_Currency{symbolic_code = <<"USD">>} + } = Cost, + original_cost = #proxy_provider_Cash{ + amount = 42000, + currency = #domain_Currency{symbolic_code = <<"RUB">>} + } + } + } = PaymentInfo, + Cost#proxy_provider_Cash{amount = Amount + 10}. + get_payment_info_scenario( #proxy_provider_PaymentInfo{payment = #proxy_provider_InvoicePayment{payment_resource = Resource}} ) -> @@ -564,6 +592,8 @@ get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"change_cash_ change_cash_increase; get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"change_cash_decrease">>}}) -> change_cash_decrease; +get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"change_currency_and_increase">>}}) -> + change_currency_and_increase; get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"no_preauth">>}}) -> no_preauth; get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"no_preauth_timeout">>}}) -> @@ -639,6 +669,7 @@ get_payment_tool_scenario({'bank_card', #domain_BankCard{token = Token} = BCard} | preauth_3ds_offsite | change_cash_increase | change_cash_decrease + | change_currency_and_increase | forbidden | unexpected_failure | unexpected_failure_no_trx @@ -668,7 +699,8 @@ make_payment_tool(Code, PSys) when Code =:= forbidden orelse Code =:= unexpected_failure orelse Code =:= unexpected_failure_when_suspended orelse - Code =:= unexpected_failure_no_trx + Code =:= unexpected_failure_no_trx orelse + Code =:= change_currency_and_increase -> ?SESSION42(make_bank_card_payment_tool(atom_to_binary(Code, utf8), PSys)); make_payment_tool({assert_contact_info, ContactInfo}, PSys) -> diff --git a/apps/hellgate/test/hg_invoice_dummy_data.erl b/apps/hellgate/test/hg_invoice_dummy_data.erl new file mode 100644 index 00000000..60bc1120 --- /dev/null +++ b/apps/hellgate/test/hg_invoice_dummy_data.erl @@ -0,0 +1,291 @@ +-module(hg_invoice_dummy_data). + +%-include_lib("hellgate/include/hg_invoice.hrl"). +-include("hg_ct_domain.hrl"). + +-export([construct_domain_fixture/0]). + +-spec construct_domain_fixture() -> _. +construct_domain_fixture() -> + [ + hg_ct_fixture:construct_currency(?cur(<<"RUB">>)), + hg_ct_fixture:construct_currency(?cur(<<"USD">>)), + hg_ct_fixture:construct_currency(?cur(<<"EUR">>)), + hg_ct_fixture:construct_currency(?cur(<<"JPY">>)), + hg_ct_fixture:construct_currency(?cur(<<"CNY">>)), + + hg_ct_fixture:construct_category(?cat(1), <<"Test category">>, test), + hg_ct_fixture:construct_category(?cat(2), <<"Generic Store">>, live), + hg_ct_fixture:construct_category(?cat(3), <<"Guns & Booze">>, live), + hg_ct_fixture:construct_category(?cat(4), <<"Flowers & Meat">>, live), + hg_ct_fixture:construct_category(?cat(5), <<"Horns & Hooves">>, live), + + hg_ct_fixture:construct_payment_method(?pmt(bank_card, ?bank_card(<<"visa-ref">>))), + + hg_ct_fixture:construct_proxy(?prx(1), <<"Dummy proxy">>), + hg_ct_fixture:construct_proxy(?prx(2), <<"Inspector proxy">>), + + hg_ct_fixture:construct_inspector(?insp(1), <<"Rejector">>, ?prx(2), #{<<"risk_score">> => <<"trusted">>}), + + hg_ct_fixture:construct_system_account_set(?sas(1)), + hg_ct_fixture:construct_external_account_set(?eas(1)), + + hg_ct_fixture:construct_payment_routing_ruleset( + ?ruleset(1), + <<"SubMain">>, + {candidates, [ + ?candidate({condition, {category_is, ?cat(1)}}, ?trm(1)), + ?candidate({condition, {category_is, ?cat(2)}}, ?trm(2)), + ?candidate({condition, {category_is, ?cat(3)}}, ?trm(3)), + ?candidate({condition, {category_is, ?cat(4)}}, ?trm(4)), + ?candidate({condition, {category_is, ?cat(5)}}, ?trm(5)) + ]} + ), + hg_ct_fixture:construct_payment_routing_ruleset( + ?ruleset(2), + <<"Prohibitions">>, + {candidates, []} + ), + + {payment_institution, #domain_PaymentInstitutionObject{ + ref = ?pinst(1), + data = #domain_PaymentInstitution{ + name = <<"Test Inc.">>, + system_account_set = {value, ?sas(1)}, + payment_routing_rules = #domain_RoutingRules{ + policies = ?ruleset(1), + prohibitions = ?ruleset(2) + }, + inspector = {value, ?insp(1)}, + residences = [], + realm = test + } + }}, + + {globals, #domain_GlobalsObject{ + ref = #domain_GlobalsRef{}, + data = #domain_Globals{ + external_account_set = {value, ?eas(1)}, + payment_institutions = ?ordset([?pinst(1)]) + } + }}, + + {term_set_hierarchy, #domain_TermSetHierarchyObject{ + ref = ?trms(1), + data = #domain_TermSetHierarchy{ + term_set = #domain_TermSet{payments = payment_service_terms()} + } + }}, + + {provider, #domain_ProviderObject{ + ref = ?prv(1), + data = #domain_Provider{ + name = <<"Brovider">>, + description = <<"A provider but bro">>, + realm = test, + proxy = #domain_Proxy{ + ref = ?prx(1), + additional = #{} + }, + accounts = hg_ct_fixture:construct_provider_account_set([ + ?cur(<<"RUB">>), + ?cur(<<"USD">>), + ?cur(<<"EUR">>), + ?cur(<<"JPY">>), + ?cur(<<"CNY">>) + ]), + terms = #domain_ProvisionTermSet{ + payments = #domain_PaymentsProvisionTerms{ + allow_exchange = {constant, true} + } + } + } + }}, + + {terminal, #domain_TerminalObject{ + ref = ?trm(1), + data = #domain_Terminal{ + name = <<"Brominal 1">>, + description = <<"Brominal 1">>, + provider_ref = ?prv(1), + terms = #domain_ProvisionTermSet{ + payments = payment_provision_terms(<<"RUB">>, 1) + } + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(2), + data = #domain_Terminal{ + name = <<"Brominal 2">>, + description = <<"Brominal 2">>, + provider_ref = ?prv(1), + terms = #domain_ProvisionTermSet{ + payments = payment_provision_terms(<<"USD">>, 2) + } + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(3), + data = #domain_Terminal{ + name = <<"Brominal 3">>, + description = <<"Brominal 3">>, + provider_ref = ?prv(1), + terms = #domain_ProvisionTermSet{ + payments = payment_provision_terms(<<"EUR">>, 3) + } + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(4), + data = #domain_Terminal{ + name = <<"Brominal 4">>, + description = <<"Brominal 4">>, + provider_ref = ?prv(1), + terms = #domain_ProvisionTermSet{ + payments = payment_provision_terms(<<"JPY">>, 4) + } + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(5), + data = #domain_Terminal{ + name = <<"Brominal 5">>, + description = <<"Brominal 5">>, + provider_ref = ?prv(1), + terms = #domain_ProvisionTermSet{ + payments = payment_provision_terms(<<"CNY">>, 5) + } + } + }}, + + hg_ct_fixture:construct_payment_system(?pmt_sys(<<"visa-ref">>), <<"visa payment system">>) + ]. + +%% Internals + +payment_service_terms() -> + #domain_PaymentsServiceTerms{ + currencies = + {value, ?ordset([?cur(<<"RUB">>), ?cur(<<"USD">>), ?cur(<<"EUR">>), ?cur(<<"JPY">>), ?cur(<<"CNY">>)])}, + categories = {value, ?ordset([?cat(1), ?cat(2), ?cat(3), ?cat(4), ?cat(5)])}, + payment_methods = {value, ?ordset([?pmt(bank_card, ?bank_card(<<"visa-ref">>))])}, + fees = { + value, + [ + ?cfpost( + {merchant, settlement}, + {system, settlement}, + ?share(45, 1000, operation_amount) + ) + ] + }, + refunds = #domain_PaymentRefundsServiceTerms{ + payment_methods = { + value, + ?ordset([ + ?pmt(bank_card, ?bank_card(<<"visa-ref">>)) + ]) + }, + eligibility_time = {value, #base_TimeSpan{minutes = 1}}, + fees = { + value, + [ + ?cfpost( + {merchant, settlement}, + {system, settlement}, + ?fixed(100, <<"RUB">>) + ) + ] + } + }, + chargebacks = #domain_PaymentChargebackServiceTerms{ + allow = {constant, true}, + fees = { + value, + [ + ?cfpost( + {merchant, settlement}, + {system, settlement}, + ?share(1, 1, surplus) + ) + ] + } + }, + cash_limit = { + value, + ?cashrng( + {inclusive, ?cash(10, <<"RUB">>)}, + {exclusive, ?cash(420000000, <<"RUB">>)} + ) + }, + attempt_limit = {value, #domain_AttemptLimit{attempts = 2}} + }. + +payment_provision_terms(Currency, Category) -> + #domain_PaymentsProvisionTerms{ + currencies = {value, ?ordset([?cur(Currency)])}, + categories = {value, ?ordset([?cat(Category)])}, + payment_methods = { + value, + ?ordset([ + ?pmt(bank_card, ?bank_card(<<"visa-ref">>)) + ]) + }, + cash_flow = { + value, + [ + ?cfpost( + {provider, settlement}, + {merchant, settlement}, + ?share(1, 1, operation_amount) + ), + ?cfpost( + {system, settlement}, + {provider, settlement}, + ?fixed(10, Currency) + ) + ] + }, + cash_limit = { + value, + ?cashrng( + {inclusive, ?cash(10, Currency)}, + {exclusive, ?cash(420000000, Currency)} + ) + }, + refunds = #domain_PaymentRefundsProvisionTerms{ + cash_flow = { + value, + [ + ?cfpost( + {merchant, settlement}, + {provider, settlement}, + ?share(1, 1, operation_amount) + ), + ?cfpost( + {system, settlement}, + {provider, settlement}, + ?fixed(10, Currency) + ) + ] + } + }, + chargebacks = #domain_PaymentChargebackProvisionTerms{ + cash_flow = { + value, + [ + ?cfpost( + {merchant, settlement}, + {provider, settlement}, + ?share(1, 1, operation_amount) + ), + ?cfpost( + {system, settlement}, + {provider, settlement}, + ?fixed(10, Currency) + ) + ] + } + }, + allow_exchange = {constant, true} + }. diff --git a/apps/hellgate/test/hg_invoice_exchange_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_exchange_tests_SUITE.erl new file mode 100644 index 00000000..c94655af --- /dev/null +++ b/apps/hellgate/test/hg_invoice_exchange_tests_SUITE.erl @@ -0,0 +1,588 @@ +-module(hg_invoice_exchange_tests_SUITE). + +-include_lib("hellgate/include/hg_invoice.hrl"). +-include_lib("hellgate/include/payment_events.hrl"). +-include_lib("hellgate/include/invoice_events.hrl"). +-include_lib("stdlib/include/assert.hrl"). +-include("hg_ct_domain.hrl"). +-include("hg_ct_invoice.hrl"). + +-export([all/0]). +-export([groups/0]). +-export([init_per_suite/1]). +-export([end_per_suite/1]). +-export([init_per_group/2]). +-export([end_per_group/2]). +-export([init_per_testcase/2]). +-export([end_per_testcase/2]). + +%% Tests +-export([payment_success/1]). +-export([payment_and_refund_with_increased_cost/1]). +-export([accept_payment_chargeback_new_body/1]). +-export([payment_adjustment_success/1]). +-export([payment_failed_exchange_rate_unknown/1]). +-export([payment_failed_exchange_rate_timeout/1]). +-export([payment_failed_exchange_rate_unexpected/1]). + +-type config() :: hg_ct_helper:config(). +-type test_case_name() :: hg_ct_helper:test_case_name(). +-type group_name() :: hg_ct_helper:group_name(). +-type test_return() :: _ | no_return(). + +%% Supervisor +-behaviour(supervisor). + +-export([init/1]). + +-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. +init([]) -> + {ok, {#{strategy => one_for_all, intensity => 1, period => 1}, []}}. + +-spec all() -> [test_case_name() | {group, group_name()}]. +all() -> + [ + payment_success, + payment_and_refund_with_increased_cost, + accept_payment_chargeback_new_body, + payment_adjustment_success, + payment_failed_exchange_rate_unknown, + payment_failed_exchange_rate_timeout, + payment_failed_exchange_rate_unexpected + ]. + +-spec groups() -> [{group_name(), list(), [test_case_name()]}]. +groups() -> + []. + +-spec init_per_suite(config()) -> config(). +init_per_suite(C) -> + CowboySpec = hg_dummy_provider:get_http_cowboy_spec(), + {Apps, Ret} = hg_ct_helper:start_apps([ + woody, + scoper, + dmt_client, + bender_client, + party_client, + hg_proto, + epg_connector, + progressor, + hellgate, + {cowboy, CowboySpec}, + snowflake + ]), + application:set_env(hellgate, currency_exchange_enabled, true), + RootUrl = maps:get(hellgate_root_url, Ret), + _ = hg_limiter_helper:init_per_suite(C), + _ = hg_domain:upsert(hg_invoice_dummy_data:construct_domain_fixture()), + PartyConfigRef = #domain_PartyConfigRef{id = hg_utils:unique_id()}, + PartyClient = {party_client:create_client(), party_client:create_context()}, + ok = hg_context:save(hg_context:create()), + %% все магазины рублёвые, но каждая категория роутится на терминалы с раными валютами + ShopConfigRef = hg_ct_helper:create_party_and_shop( + PartyConfigRef, ?cat(2), <<"RUB">>, ?trms(1), ?pinst(1), PartyClient + ), + ShopConfigRefEur = hg_ct_helper:create_party_and_shop( + PartyConfigRef, ?cat(3), <<"RUB">>, ?trms(1), ?pinst(1), PartyClient + ), + ShopConfigRefJpy = hg_ct_helper:create_party_and_shop( + PartyConfigRef, ?cat(4), <<"RUB">>, ?trms(1), ?pinst(1), PartyClient + ), + ShopConfigRefCny = hg_ct_helper:create_party_and_shop( + PartyConfigRef, ?cat(5), <<"RUB">>, ?trms(1), ?pinst(1), PartyClient + ), + ok = hg_context:cleanup(), + {ok, SupPid} = supervisor:start_link(?MODULE, []), + _ = unlink(SupPid), + ok = hg_invoice_helper:start_kv_store(SupPid), + _ = start_exchange_service_handler([{test_sup, SupPid} | C]), + NewC = [ + {party_config_ref, PartyConfigRef}, + {shop_config_ref, ShopConfigRef}, + {shop_config_ref_eur, ShopConfigRefEur}, + {shop_config_ref_jpy, ShopConfigRefJpy}, + {shop_config_ref_cny, ShopConfigRefCny}, + {root_url, RootUrl}, + {test_sup, SupPid}, + {apps, Apps} + | C + ], + ok = hg_invoice_helper:start_proxies([{hg_dummy_provider, 1, NewC}, {hg_dummy_inspector, 2, NewC}]), + NewC. + +-spec end_per_suite(config()) -> _. +end_per_suite(C) -> + _ = hg_domain:cleanup(), + _ = application:stop(progressor), + _ = hg_progressor:cleanup(), + _ = [application:stop(App) || App <- cfg(apps, C)], + hg_invoice_helper:stop_kv_store(cfg(test_sup, C)), + exit(cfg(test_sup, C), shutdown). + +-spec init_per_group(group_name(), config()) -> config(). +init_per_group(_, C) -> + C. + +-spec end_per_group(group_name(), config()) -> _. +end_per_group(_Group, _C) -> + ok. + +-spec init_per_testcase(test_case_name(), config()) -> config(). +init_per_testcase(_, C) -> + ApiClient = hg_ct_helper:create_client(hg_ct_helper:cfg(root_url, C)), + Client = hg_client_invoicing:start_link(ApiClient), + ok = hg_context:save(hg_context:create()), + [ + {client, Client} + | C + ]. + +-spec end_per_testcase(test_case_name(), config()) -> config(). +end_per_testcase(_, C) -> + C. + +%% TESTS + +-spec payment_success(config()) -> test_return(). +payment_success(C) -> + Client = cfg(client, C), + + InvoiceID = hg_invoice_helper:start_invoice(<<"rubberduck">>, hg_invoice_helper:make_due_date(10), 42000, C), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + PaymentID = hg_invoice_helper:process_payment(InvoiceID, PaymentParams, Client), + PaymentID = hg_invoice_helper:await_payment_capture(InvoiceID, PaymentID, Client), + #payproc_Invoice{payments = [Payment]} = hg_client_invoicing:get(InvoiceID, Client), + #payproc_InvoicePayment{cash_flow = CashFlow} = Payment, + [ + #domain_FinalCashFlowPosting{ + volume = ConvertedCash, + exchange_context = ExchangeContext + } + ] = lookup_posting(CashFlow, {system, settlement}, {provider, settlement}), + %% Expected Cash: converted 800 RUB from fixed 10 USD + ExpectCash = #domain_Cash{amount = 800, currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>}}, + ?assertEqual(ExpectCash, ConvertedCash), + ExpectContext = #domain_ExchangeContext{ + source_currency = <<"RUB">>, + destination_currency = <<"USD">>, + exchange_rate = #base_Rational{p = 80, q = 1} + }, + ?assertEqual(ExpectContext, ExchangeContext), + ok. + +-spec payment_and_refund_with_increased_cost(config()) -> test_return(). +payment_and_refund_with_increased_cost(C) -> + Client = cfg(client, C), + + % top up merchant account + InvoiceID2 = hg_invoice_helper:start_invoice( + <<"rubberduck">>, + hg_invoice_helper:make_due_date(10), + 42800, + C + ), + _PaymentID2 = hg_invoice_helper:execute_payment( + InvoiceID2, + hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + Client + ), + + InvoiceID = hg_invoice_helper:start_invoice(<<"rubberduck">>, hg_invoice_helper:make_due_date(10), 42000, C), + {PaymentTool, Session} = hg_dummy_provider:make_payment_tool( + change_currency_and_increase, + ?pmt_sys(<<"visa-ref">>) + ), + PaymentParams = hg_invoice_helper:make_payment_params(PaymentTool, Session, instant), + PaymentID = hg_invoice_helper:start_payment(InvoiceID, PaymentParams, Client), + PaymentID = hg_invoice_helper:await_payment_session_started(InvoiceID, PaymentID, Client, ?processed()), + [ + ?payment_ev(PaymentID, ?session_ev(?processed(), ?trx_bound(?trx_info(_)))), + ?payment_ev(PaymentID, ?cash_changed(_, _)), + ?payment_ev(PaymentID, ?session_ev(?processed(), ?session_finished(?session_succeeded()))), + ?payment_ev(PaymentID, ?cash_flow_changed(_)), + ?payment_ev(PaymentID, ?payment_status_changed(?processed())) + ] = hg_invoice_helper:next_changes(InvoiceID, 5, Client), + PaymentID = hg_invoice_helper:await_payment_capture(InvoiceID, PaymentID, Client), + #payproc_Invoice{ + payments = [ + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{ + changed_cost = + #domain_Cash{ + amount = ChangedAmount, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + } = ChangedCost + }, + route = Route, + cash_flow = CashFlow + } + ] + } = hg_client_invoicing:get(InvoiceID, Client), + %% changed cost: 42800 = 42000 RUB + (10 USD * 80) + ?assertEqual(42800, ChangedAmount), + [ + #domain_FinalCashFlowPosting{ + volume = #domain_Cash{ + amount = ChangedVolumeAmount, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + }, + exchange_context = _ExchangeContext + } + ] = lookup_posting(CashFlow, {provider, settlement}, {merchant, settlement}), + ?assertEqual(42800, ChangedVolumeAmount), + + CFContext = hg_invoice_helper:construct_ta_context(cfg(party_config_ref, C), cfg(shop_config_ref, C), Route), + PrvAccount1 = get_deprecated_cashflow_account({provider, settlement}, CashFlow, CFContext), + SysAccount1 = get_deprecated_cashflow_account({system, settlement}, CashFlow, CFContext), + MrcAccount1 = get_deprecated_cashflow_account({merchant, settlement}, CashFlow, CFContext), + + %% payment with changed cost are captured + %% now refund it + + RefundParams = make_refund_params(), + % create a refund finally + RefundID = hg_invoice_helper:execute_payment_refund(InvoiceID, PaymentID, RefundParams, Client), + #domain_InvoicePaymentRefund{status = ?refund_succeeded()} = + hg_client_invoicing:get_payment_refund(InvoiceID, PaymentID, RefundID, Client), + + % no more refunds for you + {exception, #payproc_InvalidPaymentStatus{status = ?refunded()}} = + hg_client_invoicing:refund_payment(InvoiceID, PaymentID, RefundParams, Client), + + Context = #{operation_amount => ChangedCost}, + PrvAccount2 = get_deprecated_cashflow_account({provider, settlement}, CashFlow, CFContext), + SysAccount2 = get_deprecated_cashflow_account({system, settlement}, CashFlow, CFContext), + MrcAccount2 = get_deprecated_cashflow_account({merchant, settlement}, CashFlow, CFContext), + #domain_Cash{amount = MrcAmountFixed} = hg_cashflow:compute_volume(?fixed(100, <<"RUB">>), Context), + #domain_Cash{amount = PrvAmountFixed} = hg_cashflow:compute_volume(?fixed(800, <<"RUB">>), Context), + ?assertEqual( + maps:get(own_amount, MrcAccount2), + maps:get(own_amount, MrcAccount1) - ChangedAmount - MrcAmountFixed + ), + ?assertEqual( + maps:get(own_amount, PrvAccount2), + maps:get(own_amount, PrvAccount1) + ChangedAmount + PrvAmountFixed + ), + ?assertEqual( + MrcAmountFixed - PrvAmountFixed, + maps:get(own_amount, SysAccount2) - maps:get(own_amount, SysAccount1) + ), + ok. + +-spec accept_payment_chargeback_new_body(config()) -> _ | no_return(). +accept_payment_chargeback_new_body(C) -> + %% new shop with new balances + PartyConfigRef = cfg(party_config_ref, C), + PartyPair = cfg(party_client, C), + UpdShopConfigRef = + hg_ct_helper:create_battle_ready_shop(PartyConfigRef, ?cat(2), <<"RUB">>, ?trms(1), ?pinst(1), PartyPair), + NewC = lists:keyreplace(shop_config_ref, 1, C, {shop_config_ref, UpdShopConfigRef}), + + Client = cfg(client, NewC), + Cost = 42000, + Fee = 1890, + Paid = Cost - Fee, + LevyAmount = 5000, + Levy = ?cash(LevyAmount, <<"RUB">>), + CBParams = make_chargeback_params(Levy), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + ExchangeContext = #domain_ExchangeContext{ + source_currency = <<"RUB">>, + destination_currency = <<"USD">>, + exchange_rate = #base_Rational{p = 80, q = 1} + }, + {IID, PID, SID, CB} = + hg_invoice_helper:start_chargeback(NewC, Cost, CBParams, PaymentParams), + CBID = CB#domain_InvoicePaymentChargeback.id, + [ + ?payment_ev(PID, ?chargeback_ev(CBID, ?chargeback_created(CB))), + ?payment_ev(PID, ?chargeback_ev(CBID, ?chargeback_cash_flow_changed(CF1))) + ] = hg_invoice_helper:next_changes(IID, 2, Client), + %% shared cash volume + [ + #domain_FinalCashFlowPosting{ + volume = #domain_Cash{ + amount = Cost, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + }, + exchange_context = ExchangeContext + } + ] = lookup_posting(CF1, {merchant, settlement}, {provider, settlement}), + %% fixed cash volume + [ + #domain_FinalCashFlowPosting{ + volume = #domain_Cash{ + amount = 800, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + }, + exchange_context = ExchangeContext + } + ] = lookup_posting(CF1, {system, settlement}, {provider, settlement}), + Settlement0 = hg_accounting:get_balance(SID), + Body = 40000, + AcceptParams = make_chargeback_accept_params(undefined, ?cash(Body, <<"RUB">>)), + ok = hg_client_invoicing:accept_chargeback(IID, PID, CBID, AcceptParams, Client), + [ + ?payment_ev(PID, ?chargeback_ev(CBID, ?chargeback_body_changed(_))), + ?payment_ev(PID, ?chargeback_ev(CBID, ?chargeback_target_status_changed(?chargeback_status_accepted()))), + ?payment_ev(PID, ?chargeback_ev(CBID, ?chargeback_cash_flow_changed(CF2))), + ?payment_ev(PID, ?chargeback_ev(CBID, ?chargeback_status_changed(?chargeback_status_accepted()))) + ] = hg_invoice_helper:next_changes(IID, 4, Client), + %% shared cash volume + [ + #domain_FinalCashFlowPosting{ + volume = #domain_Cash{ + amount = Body, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + }, + exchange_context = ExchangeContext + } + ] = lookup_posting(CF2, {merchant, settlement}, {provider, settlement}), + %% fixed cash volume + [ + #domain_FinalCashFlowPosting{ + volume = #domain_Cash{ + amount = 800, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + }, + exchange_context = ExchangeContext + } + ] = lookup_posting(CF2, {system, settlement}, {provider, settlement}), + Settlement1 = hg_accounting:get_balance(SID), + + ?assertEqual(Paid - Cost - LevyAmount, maps:get(min_available_amount, Settlement0)), + ?assertEqual(Paid, maps:get(max_available_amount, Settlement0)), + ?assertEqual(Paid - Body - LevyAmount, maps:get(min_available_amount, Settlement1)), + ?assertEqual(Paid - Body - LevyAmount, maps:get(max_available_amount, Settlement1)), + ok. + +-spec payment_adjustment_success(config()) -> test_return(). +payment_adjustment_success(C) -> + %% old cf : + %% merch - 1890 -> syst + %% prov - 42000 -> merch (with exchange) + %% syst - 800 -> prov (fixed, with exchange from 10 USD) + %% + %% new cf : + %% merch - 1890 -> syst + %% prov - 42000 -> merch (with exchange) + %% syst - 400 -> prov (fixed, with exchange from 5 USD) + Client = cfg(client, C), + + InvoiceID = hg_invoice_helper:start_invoice(<<"rubberduck">>, hg_invoice_helper:make_due_date(10), 42000, C), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + PaymentID = hg_invoice_helper:process_payment(InvoiceID, PaymentParams, Client), + PaymentID = hg_invoice_helper:await_payment_capture(InvoiceID, PaymentID, Client), + ExchangeContext = #domain_ExchangeContext{ + source_currency = <<"RUB">>, + destination_currency = <<"USD">>, + exchange_rate = #base_Rational{p = 80, q = 1} + }, + #payproc_Invoice{ + payments = [ + #payproc_InvoicePayment{ + route = Route, + cash_flow = CF1 + } + ] + } = hg_client_invoicing:get(InvoiceID, Client), + %% System -> Provider fixed cash volume 800 RUB (10 USD * 80) + [ + #domain_FinalCashFlowPosting{ + volume = #domain_Cash{ + amount = 800, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + }, + exchange_context = ExchangeContext + } + ] = lookup_posting(CF1, {system, settlement}, {provider, settlement}), + + CFContext = hg_invoice_helper:construct_ta_context(cfg(party_config_ref, C), cfg(shop_config_ref, C), Route), + PrvAccount1 = get_deprecated_cashflow_account({provider, settlement}, CF1, CFContext), + SysAccount1 = get_deprecated_cashflow_account({system, settlement}, CF1, CFContext), + MrcAccount1 = get_deprecated_cashflow_account({merchant, settlement}, CF1, CFContext), + + %% update terminal cashflow + ok = update_payment_terms_cashflow(?trm(2)), + + %% make an adjustment + Params = make_adjustment_params(Reason = <<"imdrunk">>), + ?adjustment(AdjustmentID, ?adjustment_pending()) = + Adjustment = + hg_client_invoicing:create_payment_adjustment(InvoiceID, PaymentID, Params, Client), + Adjustment = + #domain_InvoicePaymentAdjustment{id = AdjustmentID, reason = Reason} = + hg_client_invoicing:get_payment_adjustment(InvoiceID, PaymentID, AdjustmentID, Client), + ?payment_ev(PaymentID, ?adjustment_ev(AdjustmentID, ?adjustment_created(Adjustment))) = + hg_invoice_helper:next_change(InvoiceID, Client), + [ + ?payment_ev(PaymentID, ?adjustment_ev(AdjustmentID, ?adjustment_status_changed(?adjustment_processed()))), + ?payment_ev(PaymentID, ?adjustment_ev(AdjustmentID, ?adjustment_status_changed(?adjustment_captured(_)))) + ] = hg_invoice_helper:next_changes(InvoiceID, 2, Client), + %% verify that cash deposited correctly everywhere + #domain_InvoicePaymentAdjustment{new_cash_flow = DCF2} = Adjustment, + %% System -> Provider change fixed cash volume 400 RUB (5 USD * 80) + [ + #domain_FinalCashFlowPosting{ + volume = #domain_Cash{ + amount = 400, + currency = #domain_CurrencyRef{symbolic_code = <<"RUB">>} + }, + exchange_context = ExchangeContext + } + ] = lookup_posting(DCF2, {system, settlement}, {provider, settlement}), + + PrvAccount2 = get_deprecated_cashflow_account({provider, settlement}, DCF2, CFContext), + SysAccount2 = get_deprecated_cashflow_account({system, settlement}, DCF2, CFContext), + MrcAccount2 = get_deprecated_cashflow_account({merchant, settlement}, DCF2, CFContext), + + 0 = MrcDiff = maps:get(own_amount, MrcAccount2) - maps:get(own_amount, MrcAccount1), + -400 = PrvDiff = maps:get(own_amount, PrvAccount2) - maps:get(own_amount, PrvAccount1), + SysDiff = MrcDiff - PrvDiff, + SysDiff = maps:get(own_amount, SysAccount2) - maps:get(own_amount, SysAccount1), + ok. + +-spec payment_failed_exchange_rate_unknown(config()) -> test_return(). +payment_failed_exchange_rate_unknown(C) -> + Client = cfg(client, C), + + InvoiceID = hg_invoice_helper:start_invoice( + cfg(shop_config_ref_eur, C), + <<"rubberduck">>, + hg_invoice_helper:make_due_date(10), + 42000, + C + ), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + ?payment_state(?payment(_PaymentID)) = hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client), + _ = hg_invoice_helper:fail_payment_no_route(InvoiceID, Client), + ok. + +-spec payment_failed_exchange_rate_timeout(config()) -> test_return(). +payment_failed_exchange_rate_timeout(C) -> + Client = cfg(client, C), + + InvoiceID = hg_invoice_helper:start_invoice( + cfg(shop_config_ref_jpy, C), + <<"rubberduck">>, + hg_invoice_helper:make_due_date(10), + 42000, + C + ), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + ?payment_state(?payment(_PaymentID)) = hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client), + _ = hg_invoice_helper:fail_payment_no_route(InvoiceID, Client), + ok. + +-spec payment_failed_exchange_rate_unexpected(config()) -> test_return(). +payment_failed_exchange_rate_unexpected(C) -> + Client = cfg(client, C), + + InvoiceID = hg_invoice_helper:start_invoice( + cfg(shop_config_ref_cny, C), + <<"rubberduck">>, + hg_invoice_helper:make_due_date(10), + 42000, + C + ), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + ?payment_state(?payment(_PaymentID)) = hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client), + _ = hg_invoice_helper:fail_payment_no_route(InvoiceID, Client), + ok. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% Internals +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +start_exchange_service_handler(C) -> + Module = hg_dummy_exrates, + IP = "127.0.0.1", + Port = 32022, + ChildSpec = hg_test_proxy:get_child_spec(Module, Module, IP, Port, #{}), + {ok, _} = supervisor:start_child(hg_ct_helper:cfg(test_sup, C), ChildSpec), + hg_test_proxy:get_url(Module, IP, Port). + +cfg(Key, Config) -> + hg_ct_helper:cfg(Key, Config). + +make_chargeback_params(Levy) -> + #payproc_InvoicePaymentChargebackParams{ + id = hg_utils:unique_id(), + reason = #domain_InvoicePaymentChargebackReason{ + code = <<"CB.C0DE">>, + category = {fraud, #domain_InvoicePaymentChargebackCategoryFraud{}} + }, + levy = Levy, + occurred_at = hg_datetime:format_now() + }. + +make_chargeback_accept_params(Levy, Body) -> + #payproc_InvoicePaymentChargebackAcceptParams{ + body = Body, + levy = Levy + }. + +make_refund_params() -> + #payproc_InvoicePaymentRefundParams{ + reason = <<"ZANOZED">> + }. + +make_adjustment_params(Reason) -> + make_adjustment_params(Reason, undefined, undefined). + +make_adjustment_params(Reason, Revision, Amount) -> + #payproc_InvoicePaymentAdjustmentParams{ + reason = Reason, + scenario = + {cash_flow, #domain_InvoicePaymentAdjustmentCashFlow{ + domain_revision = Revision, + new_amount = Amount + }} + }. + +get_deprecated_cashflow_account(Type, CF, CFContext) -> + hg_invoice_helper:get_deprecated_cashflow_account(Type, CF, CFContext). + +update_payment_terms_cashflow(TerminalRef) -> + NewCashFlow = [ + ?cfpost( + {provider, settlement}, + {merchant, settlement}, + ?share(1, 1, operation_amount) + ), + ?cfpost( + {system, settlement}, + {provider, settlement}, + ?fixed(5, <<"USD">>) + ) + ], + Terminal = hg_domain:get({terminal, TerminalRef}), + TerminalTerms = Terminal#domain_Terminal.terms, + PaymentTerms = TerminalTerms#domain_ProvisionTermSet.payments, + NewTerminal = Terminal#domain_Terminal{ + terms = TerminalTerms#domain_ProvisionTermSet{ + payments = PaymentTerms#domain_PaymentsProvisionTerms{ + cash_flow = {value, NewCashFlow} + } + } + }, + _ = hg_domain:upsert( + {terminal, #domain_TerminalObject{ + ref = TerminalRef, + data = NewTerminal + }} + ), + ok. + +lookup_posting(CashFlow, Source, Destination) -> + lists:filter( + fun( + #domain_FinalCashFlowPosting{ + source = #domain_FinalCashFlowAccount{account_type = SourceAcc}, + destination = #domain_FinalCashFlowAccount{account_type = DestinationAcc} + } + ) -> + Source =:= SourceAcc andalso Destination =:= DestinationAcc + end, + CashFlow + ). diff --git a/apps/hellgate/test/hg_invoice_helper.erl b/apps/hellgate/test/hg_invoice_helper.erl index 9bf3895b..2c4f291e 100644 --- a/apps/hellgate/test/hg_invoice_helper.erl +++ b/apps/hellgate/test/hg_invoice_helper.erl @@ -9,7 +9,8 @@ -export([ start_kv_store/1, stop_kv_store/1, - start_proxies/1 + start_proxies/1, + start_service_handler/3 ]). -export([ @@ -26,6 +27,7 @@ register_payment/4, start_payment/3, start_payment_ev/2, + fail_payment_no_route/2, register_payment_ev_no_risk_scoring/2, await_payment_session_started/4, await_payment_capture/3, @@ -44,7 +46,10 @@ process_payment/3, get_payment_cost/3, make_cash/1, - make_cash/2 + make_cash/2, + execute_payment_refund/4, + get_deprecated_cashflow_account/3, + start_chargeback/4 ]). cfg(Key, C) -> @@ -83,6 +88,7 @@ start_proxies(Proxies) -> ) ). +-spec start_service_handler(_, _, _) -> binary(). start_service_handler(Module, C, HandlerOpts) -> start_service_handler(Module, Module, C, HandlerOpts). @@ -252,9 +258,13 @@ register_payment(InvoiceID, RegisterPaymentParams, WithRiskScoring, Client) -> start_payment(InvoiceID, PaymentParams, Client) -> ?payment_state(?payment(PaymentID)) = hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client), _ = start_payment_ev(InvoiceID, Client), - ?payment_ev(PaymentID, ?cash_flow_changed(_)) = - next_change(InvoiceID, Client), - PaymentID. + case next_change(InvoiceID, Client) of + ?payment_ev(PaymentID, ?cash_flow_changed(_)) -> + PaymentID; + ?payment_ev(PaymentID, ?invoice_payment_exchange_context_changed(_)) -> + ?payment_ev(PaymentID, ?cash_flow_changed(_)) = next_change(InvoiceID, Client), + PaymentID + end. -spec start_payment_ev(_, _) -> _. start_payment_ev(InvoiceID, Client) -> @@ -267,6 +277,17 @@ start_payment_ev(InvoiceID, Client) -> ] = next_changes(InvoiceID, 5, Client), Route. +-spec fail_payment_no_route(_, _) -> _. +fail_payment_no_route(InvoiceID, Client) -> + [ + ?payment_ev(PaymentID, ?payment_started(?payment_w_status(?pending()))), + ?payment_ev(PaymentID, ?shop_limit_initiated()), + ?payment_ev(PaymentID, ?shop_limit_applied()), + ?payment_ev(PaymentID, ?risk_score_changed(_RiskScore)), + ?payment_ev(PaymentID, ?payment_status_changed(?failed(_))) + ] = next_changes(InvoiceID, 5, Client), + PaymentID. + -spec register_payment_ev_no_risk_scoring(_, _) -> _. register_payment_ev_no_risk_scoring(InvoiceID, Client) -> [ @@ -429,3 +450,71 @@ await_payment_process_finish(InvoiceID, PaymentID, Client) -> ?payment_ev(PaymentID, ?payment_status_changed(?processed())) ] = next_changes(InvoiceID, 3, Client), PaymentID. + +-spec execute_payment_refund(_, _, _, _) -> _. +execute_payment_refund(InvoiceID, PaymentID, #payproc_InvoicePaymentRefundParams{cash = undefined} = Params, Client) -> + execute_payment_refund_complete(InvoiceID, PaymentID, Params, Client). + +execute_payment_refund_complete(InvoiceID, PaymentID, Params, Client) -> + ?refund_id(RefundID) = hg_client_invoicing:refund_payment(InvoiceID, PaymentID, Params, Client), + PaymentID = await_refund_created(InvoiceID, PaymentID, RefundID, Client), + PaymentID = await_refund_session_started(InvoiceID, PaymentID, RefundID, Client), + PaymentID = await_refund_payment_complete(InvoiceID, PaymentID, Client), + RefundID. + +await_refund_created(InvoiceID, PaymentID, RefundID, Client) -> + ?payment_ev(PaymentID, ?refund_ev(RefundID, ?refund_created(_Refund, _))) = + next_change(InvoiceID, Client), + PaymentID. + +await_refund_session_started(InvoiceID, PaymentID, RefundID, Client) -> + ?payment_ev(PaymentID, ?refund_ev(RefundID, ?session_ev(?refunded(), ?session_started()))) = + next_change(InvoiceID, Client), + PaymentID. + +await_refund_payment_complete(InvoiceID, PaymentID, Client) -> + PaymentID = await_sessions_restarts(PaymentID, ?refunded(), InvoiceID, Client, 0), + [ + ?payment_ev(PaymentID, ?refund_ev(_, ?session_ev(?refunded(), ?trx_bound(_)))), + ?payment_ev(PaymentID, ?refund_ev(_, ?session_ev(?refunded(), ?session_finished(?session_succeeded())))), + ?payment_ev(PaymentID, ?refund_ev(_, ?refund_status_changed(?refund_succeeded()))), + ?payment_ev(PaymentID, ?payment_status_changed(?refunded())) + ] = next_changes(InvoiceID, 4, Client), + PaymentID. + +await_sessions_restarts(PaymentID, _Target, _InvoiceID, _Client, 0) -> + PaymentID. + +-spec get_deprecated_cashflow_account(_, _, _) -> _. +get_deprecated_cashflow_account(Type, CF, CFContext) -> + ID = get_deprecated_cashflow_account_id(Type, CF, CFContext), + hg_accounting:get_balance(ID). + +get_deprecated_cashflow_account_id(Type, CF, CFContext) -> + Account = convert_transaction_account(Type, CFContext), + [ID] = [ + V + || #domain_FinalCashFlowPosting{ + destination = #domain_FinalCashFlowAccount{ + account_id = V, + account_type = T, + transaction_account = A + } + } <- CF, + T == Type, + A == Account + ], + ID. + +-spec start_chargeback(_, _, _, _) -> _. +start_chargeback(C, Cost, CBParams, PaymentParams) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + ShopConfigRef = cfg(shop_config_ref, C), + {PartyConfigRef, _Party} = hg_party:get_party(PartyConfigRef), + {ShopConfigRef, Shop} = hg_party:get_shop(ShopConfigRef, PartyConfigRef), + {SettlementID, _} = hg_invoice_utils:get_shop_account(Shop), + InvoiceID = start_invoice(ShopConfigRef, <<"rubberduck">>, make_due_date(10), Cost, C), + PaymentID = execute_payment(InvoiceID, PaymentParams, Client), + Chargeback = hg_client_invoicing:create_chargeback(InvoiceID, PaymentID, CBParams, Client), + {InvoiceID, PaymentID, SettlementID, Chargeback}. diff --git a/apps/hg_proto/src/hg_proto.erl b/apps/hg_proto/src/hg_proto.erl index 2da3e413..0c76413c 100644 --- a/apps/hg_proto/src/hg_proto.erl +++ b/apps/hg_proto/src/hg_proto.erl @@ -45,7 +45,9 @@ get_service(party_config) -> get_service(customer_management) -> {dmsl_customer_thrift, 'CustomerManagement'}; get_service(bank_card_storage) -> - {dmsl_customer_thrift, 'BankCardStorage'}. + {dmsl_customer_thrift, 'BankCardStorage'}; +get_service(rate_boss) -> + {exrates_service_thrift, 'ExchangeRateService'}. -spec get_service_spec(Name :: atom()) -> service_spec(). get_service_spec(Name) -> diff --git a/apps/routing/src/hg_route.erl b/apps/routing/src/hg_route.erl index c7b229f7..8a442644 100644 --- a/apps/routing/src/hg_route.erl +++ b/apps/routing/src/hg_route.erl @@ -13,6 +13,8 @@ -export([set_availability/3]). -export([set_conversion/3]). -export([set_priority/2]). +-export([set_rejection_reason/2]). +-export([set_exchange_context/2]). -export([route_data/1]). -export([terminal_ref/1]). @@ -25,7 +27,7 @@ -export([fd_score/1]). -export([blacklisted/1]). -export([rejection_reason/1]). --export([set_rejection_reason/2]). +-export([exchange_context/1]). -export([score/1]). -export([equal/2]). @@ -52,7 +54,8 @@ route_data := route_data(), pin_data => pin_data(), fd_overrides => fd_overrides(), - rejection_reason => route_rejection_reason() | undefined + rejection_reason => route_rejection_reason(), + exchange_context => hg_invoice_payment:exchange_context() }. -type fd_score() :: #{ @@ -167,6 +170,18 @@ set_conversion(C, V, #{route_data := Data = #{fd_score := Score}} = R) -> set_priority(V, #{route_data := Data} = R) -> R#{route_data => Data#{priority => V}}. +-spec set_rejection_reason(route_rejection_reason(), t()) -> + t(). +set_rejection_reason(Reason, R) -> + R#{rejection_reason => Reason}. + +-spec set_exchange_context(hg_invoice_payment:exchange_context() | undefined, t()) -> + t(). +set_exchange_context(undefined, R) -> + R; +set_exchange_context(V, R) -> + R#{exchange_context => V}. + -spec provider_ref(t()) -> provider_ref(). provider_ref(#{provider_ref := Ref}) -> Ref. @@ -215,16 +230,17 @@ blacklisted(_) -> 0. -spec rejection_reason(t()) -> - route_rejection_reason(). + route_rejection_reason() | undefined. rejection_reason(#{rejection_reason := V}) -> V; rejection_reason(_) -> undefined. --spec set_rejection_reason(route_rejection_reason(), t()) -> - t(). -set_rejection_reason(Reason, R) -> - R#{rejection_reason => Reason}. +-spec exchange_context(t()) -> hg_invoice_payment:exchange_context() | undefined. +exchange_context(#{exchange_context := V}) -> + V; +exchange_context(_) -> + undefined. -spec score(t()) -> score(). score(R) -> diff --git a/apps/routing/src/hg_route_collector.erl b/apps/routing/src/hg_route_collector.erl index 0ab435a7..64adecfb 100644 --- a/apps/routing/src/hg_route_collector.erl +++ b/apps/routing/src/hg_route_collector.erl @@ -146,17 +146,15 @@ get_table_prohibitions(Prohibitions, VS, Revision) -> [hg_route:t()]. fill_accepted(Predestination, Revision, VS, Routes) -> lists:foldr( - fun(Route, AccIn) -> - PRef = hg_route:provider_ref(Route), - TRef = hg_route:terminal_ref(Route), + fun(Route0, AccIn) -> try - true = acceptable_terminal(Predestination, PRef, TRef, VS, Revision), - [Route | AccIn] + Route1 = acceptable_terminal(Predestination, Route0, VS, Revision), + [Route1 | AccIn] catch {rejected, Reason} -> - [hg_route:set_accepted({false, {rejected, Reason}}, Route) | AccIn]; + [hg_route:set_accepted({false, {rejected, Reason}}, Route0) | AccIn]; error:{misconfiguration, Reason} -> - [hg_route:set_accepted({false, {misconfiguration, Reason}}, Route) | AccIn] + [hg_route:set_accepted({false, {misconfiguration, Reason}}, Route0) | AccIn] end end, [], @@ -245,24 +243,30 @@ gather_pin_info(#domain_RoutingPin{features = Features}, Ctx) -> -spec acceptable_terminal( route_predestination(), - hg_route:provider_ref(), - hg_route:terminal_ref(), + hg_route:t(), varset(), revision() -) -> true | no_return(). -acceptable_terminal(Predestination, ProviderRef, TerminalRef, VS, Revision) -> +) -> hg_route:t() | no_return(). +acceptable_terminal(Predestination, Route, VS0, Revision) -> + ProviderRef = hg_route:provider_ref(Route), + TerminalRef = hg_route:terminal_ref(Route), {Client, Context} = get_party_client(), Result = party_client_thrift:compute_provider_terminal_terms( ProviderRef, TerminalRef, Revision, - hg_varset:prepare_varset(VS), + hg_varset:prepare_varset(VS0), Client, Context ), case Result of {ok, ProvisionTermSet} -> - check_terms_acceptability(Predestination, ProvisionTermSet, VS); + {VS1, ExchangeContext} = maybe_currency_conversion( + ProvisionTermSet#domain_ProvisionTermSet.payments, + VS0 + ), + true = check_terms_acceptability(Predestination, ProvisionTermSet, VS1), + hg_route:set_exchange_context(ExchangeContext, Route); {error, #payproc_ProvisionTermSetUndefined{}} -> throw(?rejected({'ProvisionTermSet', undefined})) end. @@ -273,6 +277,41 @@ get_party_client() -> Context = hg_context:get_party_client_context(HgContext), {Client, Context}. +maybe_currency_conversion( + #domain_PaymentsProvisionTerms{ + currencies = {value, [#domain_CurrencyRef{symbolic_code = DestinationCurrency} = DestinationCurrencyRef]}, + allow_exchange = {constant, true} + }, + #{currency := #domain_CurrencyRef{symbolic_code = SourceCurrency}} = VS0 +) when SourceCurrency =/= DestinationCurrency -> + case hg_exrates:get_exchange_rate(SourceCurrency, DestinationCurrency) of + {ok, #{p := P, q := Q}} -> + ExchangeContext = #domain_ExchangeContext{ + source_currency = SourceCurrency, + destination_currency = DestinationCurrency, + exchange_rate = #base_Rational{p = P, q = Q} + }, + VS1 = VS0#{ + currency => DestinationCurrencyRef, + cost => hg_currency_converter:convert_cash(ExchangeContext, getv(cost, VS0)) + }, + {VS1, ExchangeContext}; + {error, _} -> + throw(?rejected({'PaymentsProvisionTerms', exchange_error})) + end; +maybe_currency_conversion( + #domain_PaymentsProvisionTerms{ + currencies = {value, Currencies}, + allow_exchange = {constant, true} + }, + _VS +) when erlang:length(Currencies) > 1 -> + %% If currency conversion is allowed, only one currency must be specified + throw(?rejected({'PaymentsProvisionTerms', too_many_currencies})); +maybe_currency_conversion(_Terms, VS) -> + %% in other cases we rely on check_terms_acceptability/3 + {VS, undefined}. + check_terms_acceptability(payment, Terms, VS) -> acceptable_payment_terms(Terms#domain_ProvisionTermSet.payments, VS); check_terms_acceptability(recurrent_paytool, Terms, VS) -> diff --git a/compose.yaml b/compose.yaml index a8d495ea..f8842668 100644 --- a/compose.yaml +++ b/compose.yaml @@ -30,7 +30,7 @@ services: command: /sbin/init dmt: - image: ghcr.io/valitydev/dominant-v2:sha-c9430b5 + image: ghcr.io/valitydev/dominant-v2:sha-ab393d5 command: /opt/dmt/bin/dmt foreground environment: DMT_KAFKA_ENABLED: "0" @@ -112,7 +112,7 @@ services: retries: 20 party-management: - image: ghcr.io/valitydev/party-management:sha-8a58cd5 + image: ghcr.io/valitydev/party-management:sha-e3fe0dc command: /opt/party-management/bin/party-management foreground volumes: - ./test/party-management/sys.config:/opt/party-management/releases/0.1/sys.config diff --git a/config/sys.config b/config/sys.config index 15013612..0812ea52 100644 --- a/config/sys.config +++ b/config/sys.config @@ -38,7 +38,8 @@ bank_card_storage => "http://cubasty:8022/v1/customer/bank_card", % TODO make more consistent recurrent_paytool => "http://hellgate:8022/v1/processing/recpaytool", - fault_detector => "http://fault-detector:8022/v1/fault-detector" + fault_detector => "http://fault-detector:8022/v1/fault-detector", + rate_boss => "http://rate-boss:8022/v1/ex-rate" }} ]}, diff --git a/rebar.config b/rebar.config index bacf7d6f..e4fe4ed5 100644 --- a/rebar.config +++ b/rebar.config @@ -36,7 +36,8 @@ {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.2"}}}, {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {thrift, {git, "https://github.com/valitydev/thrift_erlang.git", {tag, "v1.0.0"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.33"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.38"}}}, + {exrates_proto, {git, "https://github.com/valitydev/exrates-proto.git", {branch, "master"}}}, {payproc_errors, {git, "https://github.com/valitydev/payproc-errors-erlang.git", {branch, "master"}}}, {mg_proto, {git, "https://github.com/valitydev/machinegun-proto.git", {branch, "master"}}}, {dmt_client, {git, "https://github.com/valitydev/dmt-client.git", {tag, "v2.0.3"}}}, diff --git a/rebar.lock b/rebar.lock index 21ff1a68..f1a84ab2 100644 --- a/rebar.lock +++ b/rebar.lock @@ -31,7 +31,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"e7a302a684deba1bb18a00d1056879329219d280"}}, + {ref,"c529750144dea43ada113436762ee1e340f5503d"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git", @@ -59,6 +59,10 @@ {ref,"49716470d0e8dab5e37db55d52dea78001735a3d"}}, 0}, {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},2}, + {<<"exrates_proto">>, + {git,"https://github.com/valitydev/exrates-proto.git", + {ref,"924f813ca2bddfdcaa3b6570733212c810035304"}}, + 0}, {<<"fault_detector_proto">>, {git,"https://github.com/valitydev/fault-detector-proto.git", {ref,"32f0b845052aa352539edade5b1efdd3c4e01371"}},