diff --git a/.env b/.env index 7c91940d..953039f2 100644 --- a/.env +++ b/.env @@ -2,6 +2,6 @@ # You SHOULD specify point releases here so that build time and run time Erlang/OTPs # are the same. See: https://github.com/erlware/relx/pull/902 SERVICE_NAME=hellgate -OTP_VERSION=27.1.2 +OTP_VERSION=27.3.4 REBAR_VERSION=3.24 THRIFT_VERSION=0.14.2.3 diff --git a/.gitignore b/.gitignore index 8c9860c3..617abf58 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ tags /.image.* Makefile.env *.iml +CLAUDE.md +.claude/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..01ba9ae3 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +rebar 3.24.0 +erlang 27.3.4 diff --git a/apps/hellgate/include/hg_invoice_payment.hrl b/apps/hellgate/include/hg_invoice_payment.hrl index fa4ae914..78eb97a7 100644 --- a/apps/hellgate/include/hg_invoice_payment.hrl +++ b/apps/hellgate/include/hg_invoice_payment.hrl @@ -23,6 +23,7 @@ chargebacks = #{} :: #{hg_invoice_payment_chargeback:id() => hg_invoice_payment_chargeback:state()}, adjustments = [] :: [hg_invoice_payment:adjustment()], recurrent_token :: undefined | dmsl_domain_thrift:'Token'(), + cascade_recurrent_tokens :: undefined | hg_customer_client:cascade_tokens(), opts :: undefined | hg_invoice_payment:opts(), repair_scenario :: undefined | hg_invoice_repair:scenario(), capture_data :: undefined | hg_invoice_payment:capture_data(), diff --git a/apps/hellgate/include/payment_events.hrl b/apps/hellgate/include/payment_events.hrl index 3726c34b..5c0962b0 100644 --- a/apps/hellgate/include/payment_events.hrl +++ b/apps/hellgate/include/payment_events.hrl @@ -94,6 +94,10 @@ {invoice_payment_rec_token_acquired, #payproc_InvoicePaymentRecTokenAcquired{token = Token}} ). +-define(cascade_tokens_loaded(Tokens), + {invoice_payment_cascade_tokens_loaded, #payproc_InvoicePaymentCascadeTokensLoaded{tokens = Tokens}} +). + -define(cash_changed(OldCash, NewCash), {invoice_payment_cash_changed, #payproc_InvoicePaymentCashChanged{old_cash = OldCash, new_cash = NewCash}} ). diff --git a/apps/hellgate/src/hg_customer_client.erl b/apps/hellgate/src/hg_customer_client.erl new file mode 100644 index 00000000..30828e18 --- /dev/null +++ b/apps/hellgate/src/hg_customer_client.erl @@ -0,0 +1,166 @@ +-module(hg_customer_client). + +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). + +%% BankCard operations +-export([find_or_create_bank_card/2]). +-export([get_recurrent_tokens_by_card/2]). +-export([save_recurrent_token_by_card/3]). +-export([tokens_to_map/1]). + +%% Customer operations +-export([create_customer/1]). +-export([get_by_parent_payment/2]). +-export([get_recurrent_tokens/2]). +-export([add_payment/3]). +-export([link_bank_card/2]). + +-export_type([cascade_tokens/0]). + +-type invoice_id() :: dmsl_domain_thrift:'InvoiceID'(). +-type payment_id() :: dmsl_domain_thrift:'InvoicePaymentID'(). +-type provider_terminal_key() :: dmsl_customer_thrift:'ProviderTerminalKey'(). +-type token() :: dmsl_domain_thrift:'Token'(). +-type recurrent_token() :: dmsl_customer_thrift:'RecurrentToken'(). +-type cascade_tokens() :: #{provider_terminal_key() => token()}. + +%% BankCard operations + +-spec find_or_create_bank_card(dmsl_domain_thrift:'PartyConfigRef'(), token()) -> + dmsl_customer_thrift:'BankCard'(). +find_or_create_bank_card(PartyConfigRef, BankCardToken) -> + case find_bank_card(PartyConfigRef, BankCardToken) of + {ok, BankCard} -> + BankCard; + {exception, #customer_BankCardNotFound{}} -> + {ok, BankCard} = call( + bank_card_storage, + 'Create', + {PartyConfigRef, #customer_BankCardParams{bank_card_token = BankCardToken}} + ), + BankCard + end. + +-spec get_recurrent_tokens_by_card(dmsl_domain_thrift:'PartyConfigRef'(), token()) -> + [recurrent_token()]. +get_recurrent_tokens_by_card(PartyConfigRef, BankCardToken) -> + case find_bank_card(PartyConfigRef, BankCardToken) of + {ok, #customer_BankCard{id = BankCardID}} -> + {ok, Tokens} = call(bank_card_storage, 'GetRecurrentTokens', {BankCardID}), + Tokens; + {exception, #customer_BankCardNotFound{}} -> + [] + end. + +-spec save_recurrent_token_by_card( + dmsl_domain_thrift:'PartyConfigRef'(), + token(), + {dmsl_domain_thrift:'PaymentRoute'(), token()} +) -> recurrent_token(). +save_recurrent_token_by_card( + PartyConfigRef, + BankCardToken, + {#domain_PaymentRoute{provider = ProviderRef, terminal = TerminalRef}, RecToken} +) -> + #customer_BankCard{id = BankCardID} = find_or_create_bank_card(PartyConfigRef, BankCardToken), + {ok, SavedToken} = call( + bank_card_storage, + 'AddRecurrentToken', + {#customer_RecurrentTokenParams{ + bank_card_id = BankCardID, + provider_ref = ProviderRef, + terminal_ref = TerminalRef, + token = RecToken + }} + ), + SavedToken. + +-spec tokens_to_map([recurrent_token()]) -> cascade_tokens(). +tokens_to_map(Tokens) -> + lists:foldl(fun token_to_map_entry/2, #{}, Tokens). + +%% Customer operations + +-spec create_customer(dmsl_domain_thrift:'PartyConfigRef'()) -> dmsl_customer_thrift:'Customer'(). +create_customer(PartyConfigRef) -> + {ok, Customer} = call(customer_management, 'Create', {#customer_CustomerParams{party_ref = PartyConfigRef}}), + Customer. + +-spec get_by_parent_payment(invoice_id(), payment_id()) -> + {ok, dmsl_customer_thrift:'CustomerState'()} | {exception, term()}. +get_by_parent_payment(InvoiceID, PaymentID) -> + call(customer_management, 'GetByParentPayment', {InvoiceID, PaymentID}). + +-spec get_recurrent_tokens(invoice_id(), payment_id()) -> [recurrent_token()]. +get_recurrent_tokens(InvoiceID, PaymentID) -> + case call(customer_management, 'GetByParentPayment', {InvoiceID, PaymentID}) of + {ok, #customer_CustomerState{bank_card_refs = BankCardRefs}} -> + lists:flatmap(fun collect_bank_card_tokens/1, BankCardRefs); + {exception, #customer_CustomerNotFound{}} -> + []; + {exception, #customer_InvalidRecurrentParent{}} -> + [] + end. + +-spec add_payment(dmsl_customer_thrift:'CustomerID'(), invoice_id(), payment_id()) -> ok. +add_payment(CustomerID, InvoiceID, PaymentID) -> + {ok, ok} = call(customer_management, 'AddPayment', {CustomerID, InvoiceID, PaymentID}), + ok. + +-spec link_bank_card(dmsl_customer_thrift:'CustomerID'(), token()) -> ok. +link_bank_card(CustomerID, BankCardToken) -> + {ok, _} = call( + customer_management, + 'AddBankCard', + {CustomerID, #customer_BankCardParams{bank_card_token = BankCardToken}} + ), + ok. + +%% Internal + +find_bank_card(PartyConfigRef, BankCardToken) -> + SearchParams = #customer_BankCardSearchParams{ + bank_card_token = BankCardToken, + party_ref = PartyConfigRef + }, + call(bank_card_storage, 'Find', {SearchParams}). + +collect_bank_card_tokens(#customer_BankCardRef{id = BankCardID}) -> + {ok, Tokens} = call(bank_card_storage, 'GetRecurrentTokens', {BankCardID}), + Tokens. + +token_to_map_entry( + #customer_RecurrentToken{ + provider_ref = ProviderRef, + terminal_ref = TerminalRef, + token = Token + }, + Acc +) -> + Key = #customer_ProviderTerminalKey{ + provider_ref = ProviderRef, + terminal_ref = TerminalRef + }, + Acc#{Key => Token}. + +call(ServiceName, Function, Args) -> + Service = hg_proto:get_service(ServiceName), + Opts = hg_woody_wrapper:get_service_options(ServiceName), + WoodyContext = + try + hg_context:get_woody_context(hg_context:load()) + catch + error:badarg -> woody_context:new() + end, + Request = {Service, Function, Args}, + woody_client:call( + Request, + Opts#{ + event_handler => { + scoper_woody_event_handler, + genlib_app:env(hellgate, scoper_event_handler_options, #{}) + } + }, + WoodyContext + ). diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 26874612..fe77d140 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -20,6 +20,7 @@ -include_lib("damsel/include/dmsl_proxy_provider_thrift.hrl"). -include_lib("damsel/include/dmsl_payproc_thrift.hrl"). -include_lib("damsel/include/dmsl_payproc_error_thrift.hrl"). +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). -include_lib("limiter_proto/include/limproto_limiter_thrift.hrl"). @@ -413,7 +414,8 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> make_recurrent = MakeRecurrent, context = Context, external_id = ExternalID, - processing_deadline = Deadline + processing_deadline = Deadline, + customer_id = CustomerID } = Params, Revision = hg_domain:head(), PartyConfigRef = get_party_config_ref(Opts), @@ -434,15 +436,57 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> Revision, genlib:define(MakeRecurrent, false) ), + InheritedCustomerID = maybe_inherit_customer_id(CustomerID, VS0), Payment2 = Payment1#domain_InvoicePayment{ payer_session_info = PayerSessionInfo, context = Context, external_id = ExternalID, - processing_deadline = Deadline + processing_deadline = Deadline, + customer_id = InheritedCustomerID }, - Events = [?payment_started(Payment2)], + CascadeTokenEvents = + case PayerParams of + {recurrent, #payproc_RecurrentPayerParams{recurrent_parent = ?recurrent_parent(_InvID, _PmtID)}} -> + case get_bank_card_token(Payer) of + undefined -> + []; + BCT -> + case hg_customer_client:get_recurrent_tokens_by_card(PartyConfigRef, BCT) of + [_ | _] = Tokens -> + [?cascade_tokens_loaded(Tokens)]; + [] -> + seed_bank_card_from_parent(PartyConfigRef, BCT, VS0) + end + end; + _ -> + [] + end, + Events = [?payment_started(Payment2)] ++ CascadeTokenEvents, {collapse_changes(Events, undefined, #{}), {Events, hg_machine_action:instant()}}. +seed_bank_card_from_parent(PartyConfigRef, BCT, #{parent_payment := ParentPayment}) -> + case get_recurrent_token(ParentPayment) of + undefined -> + []; + RecToken -> + Route = get_route(ParentPayment), + SavedToken = hg_customer_client:save_recurrent_token_by_card(PartyConfigRef, BCT, {Route, RecToken}), + [?cascade_tokens_loaded([SavedToken])] + end; +seed_bank_card_from_parent(_PartyConfigRef, _BCT, _VS) -> + []. + +maybe_inherit_customer_id(undefined, #{parent_payment := ParentPayment}) -> + (get_payment(ParentPayment))#domain_InvoicePayment.customer_id; +maybe_inherit_customer_id(CustomerID, #{parent_payment := ParentPayment}) -> + case (get_payment(ParentPayment))#domain_InvoicePayment.customer_id of + CustomerID -> CustomerID; + undefined -> CustomerID; + _Other -> throw(#payproc_InvalidRecurrentParentPayment{details = <<"Customer ID mismatch with parent">>}) + end; +maybe_inherit_customer_id(CustomerID, _VS) -> + CustomerID. + get_merchant_payments_terms(Opts, Revision, _Timestamp, VS) -> Shop = get_shop(Opts, Revision), TermSet = hg_invoice_utils:compute_shop_terms(Revision, Shop, VS), @@ -1914,18 +1958,36 @@ run_routing_decision_pipeline(Ctx0, VS, St) -> %% NOTE Since this is routing step then current attempt is not yet %% accounted for in `St`. NewIter = get_iter(St) + 1, + {TokenFilterFn, ChooseRouteFn} = cascade_pipeline_fns(St), hg_routing_ctx:pipeline( Ctx0, [ fun(Ctx) -> filter_attempted_routes(Ctx, St) end, + TokenFilterFn, fun(Ctx) -> filter_routes_with_limit_hold(Ctx, VS, NewIter, St) end, fun(Ctx) -> filter_routes_by_limit_overflow(Ctx, VS, NewIter, St) end, fun(Ctx) -> hg_routing:filter_by_blacklist(Ctx, build_blacklist_context(St)) end, fun hg_routing:filter_by_critical_provider_status/1, - fun hg_routing:choose_route_with_ctx/1 + ChooseRouteFn ] ). +%% First attempt with cascade tokens: skip token filter (parent route always has a token, +%% and we need all candidates visible for cascade viability check), force parent route. +%% Retries: filter by tokens (only routes with tokens are valid cascade targets), normal selection. +cascade_pipeline_fns(#st{cascade_recurrent_tokens = Tokens, routes = []}) when Tokens =/= undefined -> + {fun(Ctx) -> Ctx end, fun choose_parent_route/1}; +cascade_pipeline_fns(#st{cascade_recurrent_tokens = Tokens} = St) when Tokens =/= undefined -> + {fun(Ctx) -> filter_routes_by_recurrent_tokens(Ctx, St) end, fun hg_routing:choose_route_with_ctx/1}; +cascade_pipeline_fns(St) -> + {fun(Ctx) -> filter_routes_by_recurrent_tokens(Ctx, St) end, fun hg_routing:choose_route_with_ctx/1}. + +choose_parent_route(Ctx) -> + Candidates = hg_routing_ctx:candidates(Ctx), + %% Parent route is first in candidates (prepended in build_routing_context) + ParentRoute = hd(Candidates), + hg_routing_ctx:set_choosen(ParentRoute, #{}, Ctx). + produce_routing_events(#{error := Error} = Ctx, Revision, St) when Error =/= undefined -> %% TODO Pass failure subcode from error. Say, if last candidates were %% rejected because of provider gone critical, then use subcode to highlight @@ -1980,6 +2042,16 @@ route_args(St) -> PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision), {PaymentInstitution, VS3, Revision}. +build_routing_context(PaymentInstitution, VS, Revision, #st{cascade_recurrent_tokens = CascadeTokens} = St) when + CascadeTokens =/= undefined +-> + %% Parent route first, then other gathered routes for cascade + Payer = get_payment_payer(St), + {ok, ParentPaymentRoute} = get_predefined_route(Payer), + ParentRoute = hg_route:from_payment_route(ParentPaymentRoute), + GatheredCtx = gather_routes(PaymentInstitution, VS, Revision, St), + OtherCandidates = hg_routing_ctx:candidates(GatheredCtx), + hg_routing_ctx:new([ParentRoute | OtherCandidates]); build_routing_context(PaymentInstitution, VS, Revision, St) -> Payer = get_payment_payer(St), case get_predefined_route(Payer) of @@ -2022,6 +2094,27 @@ filter_attempted_routes(Ctx, #st{routes = AttemptedRoutes}) -> AttemptedRoutes ). +filter_routes_by_recurrent_tokens(Ctx, #st{cascade_recurrent_tokens = undefined}) -> + Ctx; +filter_routes_by_recurrent_tokens(Ctx, #st{cascade_recurrent_tokens = Tokens}) -> + lists:foldl( + fun(Route, C) -> + Key = #customer_ProviderTerminalKey{ + provider_ref = hg_route:provider_ref(Route), + terminal_ref = hg_route:terminal_ref(Route) + }, + case maps:is_key(Key, Tokens) of + true -> + C; + false -> + RejectedRoute = hg_route:to_rejected_route(Route, {recurrent_token_missing, undefined}), + hg_routing_ctx:reject(recurrent_token_missing, RejectedRoute, C) + end + end, + Ctx, + hg_routing_ctx:candidates(Ctx) + ). + handle_choose_route_error(Error, Events, St, Action) -> Failure = construct_routing_failure(Error), process_failure(get_activity(St), Events, Action, Failure, St). @@ -2330,6 +2423,7 @@ process_result({payment, finalizing_accounter}, Action, St) -> rollback_payment_cashflow(St) end, check_recurrent_token(St), + _ = maybe_save_recurrent_token_to_customer(St), NewAction = get_action(Target, Action, St), {done, {[?payment_status_changed(Target)], NewAction}}. @@ -2389,6 +2483,62 @@ check_recurrent_token(#st{ check_recurrent_token(_) -> ok. +maybe_save_recurrent_token_to_customer( + #st{ + payment = #domain_InvoicePayment{ + id = PaymentID, + customer_id = CustomerID, + payer = Payer + }, + recurrent_token = RecToken + } = St +) when CustomerID =/= undefined -> + InvoiceID = get_invoice_id(get_invoice(get_opts(St))), + hg_customer_client:add_payment(CustomerID, InvoiceID, PaymentID), + _ = maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St), + maybe_link_bankcard_to_customer(CustomerID, Payer); +maybe_save_recurrent_token_to_customer( + #st{ + payment = #domain_InvoicePayment{ + payer = Payer + }, + recurrent_token = RecToken + } = St +) -> + maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St). + +maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St) when RecToken =/= undefined -> + case get_bank_card_token(Payer) of + undefined -> + ok; + BCT -> + PartyConfigRef = get_party_config_ref(get_opts(St)), + Route = get_route(St), + hg_customer_client:save_recurrent_token_by_card(PartyConfigRef, BCT, {Route, RecToken}) + end; +maybe_save_recurrent_token_to_bankcard(_, _, _) -> + ok. + +maybe_link_bankcard_to_customer(CustomerID, Payer) -> + case get_bank_card_token(Payer) of + undefined -> ok; + BCT -> hg_customer_client:link_bank_card(CustomerID, BCT) + end. + +get_bank_card_token( + ?payment_resource_payer( + #domain_DisposablePaymentResource{ + payment_tool = {bank_card, #domain_BankCard{token = Token}} + }, + _ + ) +) -> + Token; +get_bank_card_token(?recurrent_payer({bank_card, #domain_BankCard{token = Token}}, _, _)) -> + Token; +get_bank_card_token(_) -> + undefined. + choose_fd_operation_status_for_failure({failure, Failure}) -> payproc_errors:match('PaymentFailure', Failure, fun do_choose_fd_operation_status_for_failure/1); choose_fd_operation_status_for_failure(_Failure) -> @@ -2779,7 +2929,7 @@ construct_payment_info(St, Opts) -> #proxy_provider_PaymentInfo{ shop = construct_proxy_shop(get_shop_obj(Opts, Revision)), invoice = construct_proxy_invoice(get_invoice(Opts)), - payment = construct_proxy_payment(Payment, get_trx(St)) + payment = construct_proxy_payment(Payment, get_trx(St), St) } ). @@ -2811,7 +2961,8 @@ construct_proxy_payment( skip_recurrent = SkipRecurrent, processing_deadline = Deadline }, - Trx + Trx, + St ) -> ContactInfo = get_contact_info(Payer), PaymentTool = get_payer_payment_tool(Payer), @@ -2819,7 +2970,7 @@ construct_proxy_payment( id = ID, created_at = CreatedAt, trx = Trx, - payment_resource = construct_payment_resource(Payer), + 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), @@ -2829,9 +2980,23 @@ construct_proxy_payment( processing_deadline = Deadline }. -construct_payment_resource(?payment_resource_payer(Resource, _)) -> +construct_payment_resource(?payment_resource_payer(Resource, _), _St) -> {disposable_payment_resource, Resource}; -construct_payment_resource(?recurrent_payer(PaymentTool, ?recurrent_parent(InvoiceID, PaymentID), _)) -> +construct_payment_resource( + ?recurrent_payer(PaymentTool, ?recurrent_parent(_InvoiceID, _PaymentID), _), + #st{cascade_recurrent_tokens = Tokens} = St +) when Tokens =/= undefined -> + #domain_PaymentRoute{provider = ProviderRef, terminal = TerminalRef} = get_route(St), + Key = #customer_ProviderTerminalKey{ + provider_ref = ProviderRef, + terminal_ref = TerminalRef + }, + RecToken = maps:get(Key, Tokens), + {recurrent_payment_resource, #proxy_provider_RecurrentPaymentResource{ + payment_tool = PaymentTool, + rec_token = RecToken + }}; +construct_payment_resource(?recurrent_payer(PaymentTool, ?recurrent_parent(InvoiceID, PaymentID), _), _St) -> PreviousPayment = get_payment_state(InvoiceID, PaymentID), RecToken = get_recurrent_token(PreviousPayment), {recurrent_payment_resource, #proxy_provider_RecurrentPaymentResource{ @@ -3119,6 +3284,8 @@ merge_change(Change = ?cash_flow_changed(CashFlow), #st{activity = Activity} = S merge_change(Change = ?rec_token_acquired(Token), #st{} = St, Opts) -> _ = validate_transition([{payment, processing_session}, {payment, finalizing_session}], Change, St, Opts), St#st{recurrent_token = Token}; +merge_change(?cascade_tokens_loaded(Tokens), #st{} = St, _Opts) -> + St#st{cascade_recurrent_tokens = hg_customer_client:tokens_to_map(Tokens)}; merge_change(Change = ?cash_changed(_OldCash, NewCash), #st{} = St, Opts) -> _ = validate_transition( [{adjustment_new, latest_adjustment_id(St)}, {payment, processing_session}], diff --git a/apps/hellgate/test/hg_ct_helper.erl b/apps/hellgate/test/hg_ct_helper.erl index 7edfcfd0..b28777c3 100644 --- a/apps/hellgate/test/hg_ct_helper.erl +++ b/apps/hellgate/test/hg_ct_helper.erl @@ -164,7 +164,9 @@ start_app(hg_proto = AppName) -> limiter => #{ url => <<"http://limiter:8022/v1/limiter">>, transport_opts => #{} - } + }, + 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_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index f6ac5e31..93471ae0 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -7,6 +7,9 @@ -include("invoice_events.hrl"). -include("payment_events.hrl"). +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). +-include_lib("stdlib/include/assert.hrl"). + -export([init_per_suite/1]). -export([end_per_suite/1]). @@ -27,6 +30,17 @@ -export([not_permitted_recurrent_test/1]). -export([not_exists_invoice_test/1]). -export([not_exists_payment_test/1]). +-export([customer_id_stored_test/1]). +-export([customer_id_stored_no_parent_test/1]). +-export([regular_payment_saves_to_cubasty_test/1]). +-export([cascade_tokens_filter_success_test/1]). +-export([cascade_recurrent_payment_success_test/1]). +-export([different_customer_id_test/1]). +-export([recurrent_no_customer_bankcard_lookup_test/1]). +-export([make_recurrent_saves_token_without_customer_test/1]). +-export([new_client_old_card_cascade_test/1]). +-export([cascade_exhaustion_test/1]). +-export([cascade_routing_filter_test/1]). %% Internal types @@ -60,6 +74,7 @@ init([]) -> all() -> [ {group, basic_operations}, + {group, cascade_tokens}, {group, domain_affecting_operations} ]. @@ -79,6 +94,19 @@ groups() -> ]}, {domain_affecting_operations, [], [ not_permitted_recurrent_test + ]}, + {cascade_tokens, [], [ + customer_id_stored_test, + customer_id_stored_no_parent_test, + different_customer_id_test, + regular_payment_saves_to_cubasty_test, + cascade_tokens_filter_success_test, + cascade_recurrent_payment_success_test, + make_recurrent_saves_token_without_customer_test, + recurrent_no_customer_bankcard_lookup_test, + new_client_old_card_cascade_test, + cascade_exhaustion_test, + cascade_routing_filter_test ]} ]. @@ -162,6 +190,18 @@ init_per_testcase(Name, C) -> ]. -spec end_per_testcase(test_case_name(), config()) -> ok. +end_per_testcase(cascade_recurrent_payment_success_test, _C) -> + restore_domain_after_cascade(), + ok; +end_per_testcase(new_client_old_card_cascade_test, _C) -> + restore_domain_after_cascade(), + ok; +end_per_testcase(cascade_exhaustion_test, _C) -> + restore_domain_after_cascade(), + ok; +end_per_testcase(cascade_routing_filter_test, _C) -> + restore_domain_after_cascade(), + ok; end_per_testcase(_Name, _C) -> ok. @@ -206,7 +246,7 @@ register_parent_payment_test(C) -> Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), %% first payment in recurrent session Route = ?route(?prv(1), ?trm(1)), - {PaymentTool, Session} = hg_dummy_provider:make_payment_tool(no_preauth, ?pmt_sys(<<"visa-ref">>)), + {PaymentTool, Session} = make_unique_payment_tool(?pmt_sys(<<"visa-ref">>)), PaymentParams = #payproc_RegisterInvoicePaymentParams{ payer_params = {payment_resource, #payproc_PaymentResourcePayerParams{ @@ -396,6 +436,309 @@ construct_proxy(ID, Url, Options) -> } }}. +%% Tests: cascade tokens + +-spec customer_id_stored_test(config()) -> test_result(). +customer_id_stored_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + #customer_Customer{id = CustomerID} = hg_customer_client:create_customer(PartyConfigRef), + %% Parent payment with customer_id set + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1BaseParams = make_payment_params(?pmt_sys(<<"visa-ref">>)), + Payment1Params = Payment1BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerID}, + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Child recurrent payment inherits customer_id from parent — not passed explicitly + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + Payment2Params = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + {ok, Payment2ID} = start_payment(Invoice2ID, Payment2Params, Client), + Payment2ID = await_payment_capture(Invoice2ID, Payment2ID, Client), + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{customer_id = StoredCustomerID} + } = hg_client_invoicing:get_payment(Invoice2ID, Payment2ID, Client), + ?assertEqual(CustomerID, StoredCustomerID). + +-spec customer_id_stored_no_parent_test(config()) -> test_result(). +customer_id_stored_no_parent_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + #customer_Customer{id = CustomerID} = hg_customer_client:create_customer(PartyConfigRef), + InvoiceID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + BaseParams = make_payment_params(?pmt_sys(<<"visa-ref">>)), + PaymentParams = BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerID}, + {ok, PaymentID} = start_payment(InvoiceID, PaymentParams, Client), + PaymentID = await_payment_capture(InvoiceID, PaymentID, Client), + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{customer_id = StoredCustomerID} + } = hg_client_invoicing:get_payment(InvoiceID, PaymentID, Client), + ?assertEqual(CustomerID, StoredCustomerID). + +-spec different_customer_id_test(config()) -> test_result(). +different_customer_id_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + %% Create two different customers + #customer_Customer{id = CustomerA} = hg_customer_client:create_customer(PartyConfigRef), + #customer_Customer{id = CustomerB} = hg_customer_client:create_customer(PartyConfigRef), + %% Parent payment with CustomerA + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1BaseParams = make_payment_params(?pmt_sys(<<"visa-ref">>)), + Payment1Params = Payment1BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerA}, + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Child recurrent payment with different CustomerB should be rejected + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + BaseParams = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + Payment2Params = BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerB}, + {error, #payproc_InvalidRecurrentParentPayment{details = <<"Customer ID mismatch with parent">>}} = + start_payment(Invoice2ID, Payment2Params, Client). + +-spec regular_payment_saves_to_cubasty_test(config()) -> test_result(). +regular_payment_saves_to_cubasty_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + #customer_Customer{id = CustomerID} = hg_customer_client:create_customer(PartyConfigRef), + %% Non-recurrent payment with customer_id — payment linked, no tokens saved + InvoiceID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + BaseParams = make_payment_params(false, undefined, ?pmt_sys(<<"visa-ref">>)), + PaymentParams = BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerID}, + {ok, PaymentID} = start_payment(InvoiceID, PaymentParams, Client), + PaymentID = await_payment_capture(InvoiceID, PaymentID, Client), + %% Payment is linked to customer in cubasty + {ok, State} = hg_customer_client:get_by_parent_payment(InvoiceID, PaymentID), + ?assertEqual(CustomerID, State#customer_CustomerState.customer#customer_Customer.id), + %% But no recurrent tokens saved (make_recurrent=false) + [] = hg_customer_client:get_recurrent_tokens(InvoiceID, PaymentID). + +-spec cascade_tokens_filter_success_test(config()) -> test_result(). +cascade_tokens_filter_success_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + #customer_Customer{id = CustomerID} = hg_customer_client:create_customer(PartyConfigRef), + %% First payment with customer_id + make_recurrent=true + %% Hellgate auto-saves bank card, recurrent token, and payment to cubasty + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1BaseParams = make_payment_params(?pmt_sys(<<"visa-ref">>)), + Payment1Params = Payment1BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerID}, + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Second recurrent payment: hellgate queries cubasty via GetByParentPayment, + %% finds cascade tokens saved by first payment + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + Payment2Params = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + {ok, Payment2ID} = start_payment(Invoice2ID, Payment2Params, Client), + Payment2ID = await_payment_capture(Invoice2ID, Payment2ID, Client), + ?invoice_state( + ?invoice_w_status(?invoice_paid()), + [?payment_state(?payment_w_status(Payment2ID, ?captured()))] + ) = hg_client_invoicing:get(Invoice2ID, Client). + +-spec cascade_recurrent_payment_success_test(config()) -> test_result(). +cascade_recurrent_payment_success_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + %% Step 1: Create customer, then parent payment with customer_id on ?prv(1)/?trm(1) + %% Hellgate auto-saves bank card, recurrent token, and payment link to cubasty + #customer_Customer{id = CustomerID} = hg_customer_client:create_customer(PartyConfigRef), + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1BaseParams = make_payment_params(?pmt_sys(<<"visa-ref">>)), + Payment1Params = Payment1BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerID}, + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Verify parent payment route is ?prv(1)/?trm(1) and tokens are saved in cubasty + ?invoice_state( + ?invoice_w_status(?invoice_paid()), + [Payment1St] + ) = hg_client_invoicing:get(Invoice1ID, Client), + #domain_PaymentRoute{provider = ?prv(1), terminal = ?trm(1)} = + Payment1St#payproc_InvoicePayment.route, + [#customer_RecurrentToken{provider_ref = ?prv(1), terminal_ref = ?trm(1)}] = + hg_customer_client:get_recurrent_tokens(Invoice1ID, Payment1ID), + %% Step 2: Make ?prv(1) always fail, add ?prv(2)/?trm(2) as cascade fallback + _ = hg_domain:upsert(construct_cascade_fixture()), + %% Step 3: Add cascade token for ?prv(2)/?trm(2) to existing bank card + #payproc_InvoicePayment{payment = #domain_InvoicePayment{payer = Payer1}} = + hg_client_invoicing:get_payment(Invoice1ID, Payment1ID, Client), + BCT1 = get_bank_card_token_from_payer(Payer1), + _ = hg_customer_client:save_recurrent_token_by_card( + PartyConfigRef, + BCT1, + {#domain_PaymentRoute{provider = ?prv(2), terminal = ?trm(2)}, <<"cascade-token-prv2">>} + ), + %% Step 4: Parent route ?prv(1)/?trm(1) tried first (now fails), cascades to ?prv(2)/?trm(2) + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + Payment2Params = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + {ok, Payment2ID} = start_payment(Invoice2ID, Payment2Params, Client), + Payment2ID = await_payment_capture(Invoice2ID, Payment2ID, Client), + ?invoice_state( + ?invoice_w_status(?invoice_paid()), + [Payment2St] + ) = hg_client_invoicing:get(Invoice2ID, Client), + ?payment_state(?payment_w_status(Payment2ID, ?captured())) = Payment2St, + #domain_PaymentRoute{provider = ?prv(2), terminal = ?trm(2)} = + Payment2St#payproc_InvoicePayment.route. + +-spec make_recurrent_saves_token_without_customer_test(config()) -> test_result(). +make_recurrent_saves_token_without_customer_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + %% Payment with make_recurrent=true but NO customer_id + InvoiceID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + PaymentParams = make_payment_params(?pmt_sys(<<"visa-ref">>)), + {ok, PaymentID} = start_payment(InvoiceID, PaymentParams, Client), + PaymentID = await_payment_capture(InvoiceID, PaymentID, Client), + %% Verify: no customer_id on the payment + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{customer_id = undefined, payer = Payer} + } = hg_client_invoicing:get_payment(InvoiceID, PaymentID, Client), + %% Verify: recurrent token was saved to BankCard via bank_card_token lookup + BCT = get_bank_card_token_from_payer(Payer), + Tokens = hg_customer_client:get_recurrent_tokens_by_card(PartyConfigRef, BCT), + ?assertMatch([#customer_RecurrentToken{provider_ref = ?prv(1), terminal_ref = ?trm(1)} | _], Tokens). + +-spec recurrent_no_customer_bankcard_lookup_test(config()) -> test_result(). +recurrent_no_customer_bankcard_lookup_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + %% Step 1: Parent payment with make_recurrent=true, NO customer_id + %% Token gets auto-saved to BankCard + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1Params = make_payment_params(?pmt_sys(<<"visa-ref">>)), + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Step 2: Child recurrent payment, also NO customer_id + %% System should find BankCard by bank_card_token and load cascade tokens + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + Payment2Params = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + {ok, Payment2ID} = start_payment(Invoice2ID, Payment2Params, Client), + Payment2ID = await_payment_capture(Invoice2ID, Payment2ID, Client), + %% Verify: payment succeeded, no customer_id + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{customer_id = undefined} + } = hg_client_invoicing:get_payment(Invoice2ID, Payment2ID, Client), + %% Verify: BankCard now has token(s) accumulated + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{payer = Payer} + } = hg_client_invoicing:get_payment(Invoice1ID, Payment1ID, Client), + BCT = get_bank_card_token_from_payer(Payer), + Tokens = hg_customer_client:get_recurrent_tokens_by_card(PartyConfigRef, BCT), + ?assert(length(Tokens) >= 1). + +-spec new_client_old_card_cascade_test(config()) -> test_result(). +new_client_old_card_cascade_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + %% Step 1: First payment with make_recurrent=true, no customer + %% This saves recurrent token to BankCard for ?prv(1)/?trm(1) + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1Params = make_payment_params(?pmt_sys(<<"visa-ref">>)), + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Get bank card token from first payment + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{payer = Payer1} + } = hg_client_invoicing:get_payment(Invoice1ID, Payment1ID, Client), + BCT = get_bank_card_token_from_payer(Payer1), + %% Step 2: Manually add a second recurrent token for ?prv(2)/?trm(2) to the same BankCard + #customer_BankCard{} = hg_customer_client:find_or_create_bank_card(PartyConfigRef, BCT), + _ = hg_customer_client:save_recurrent_token_by_card( + PartyConfigRef, + BCT, + {#domain_PaymentRoute{provider = ?prv(2), terminal = ?trm(2)}, <<"cascade-token-prv2">>} + ), + %% Step 3: Make ?prv(1) fail, add ?prv(2)/?trm(2) to routing + _ = hg_domain:upsert(construct_cascade_fixture()), + %% Step 4: "New client" makes a recurrent payment using the same card (no customer) + %% Should cascade from failing ?prv(1) to ?prv(2) + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + Payment2Params = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + {ok, Payment2ID} = start_payment(Invoice2ID, Payment2Params, Client), + Payment2ID = await_payment_capture(Invoice2ID, Payment2ID, Client), + %% Verify: cascade succeeded via ?prv(2)/?trm(2) + ?invoice_state( + ?invoice_w_status(?invoice_paid()), + [Payment2St] + ) = hg_client_invoicing:get(Invoice2ID, Client), + ?payment_state(?payment_w_status(Payment2ID, ?captured())) = Payment2St, + #domain_PaymentRoute{provider = ?prv(2), terminal = ?trm(2)} = + Payment2St#payproc_InvoicePayment.route. + +-spec cascade_exhaustion_test(config()) -> test_result(). +cascade_exhaustion_test(C) -> + Client = cfg(client, C), + %% Step 1: Parent payment, no customer + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1Params = make_payment_params(?pmt_sys(<<"visa-ref">>)), + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Step 2: Make ALL providers fail + _ = hg_domain:upsert(construct_all_fail_fixture()), + %% Step 3: Recurrent payment should exhaust cascade and fail + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + Payment2Params = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + {ok, Payment2ID} = start_payment(Invoice2ID, Payment2Params, Client), + %% Await payment failure + Pattern = [ + ?evp(?payment_ev(Payment2ID, ?payment_status_changed(?failed(_)))) + ], + {ok, _Events} = await_events(Invoice2ID, Pattern, Client), + ?invoice_state( + _, + [?payment_state(?payment_w_status(Payment2ID, ?failed(_)))] + ) = hg_client_invoicing:get(Invoice2ID, Client). + +%% Tokens don't bypass routing: BankCard has tokens for prv(1), prv(2), prv(3), +%% but routing only includes prv(1) and prv(3). prv(2)'s token must not be used. +-spec cascade_routing_filter_test(config()) -> test_result(). +cascade_routing_filter_test(C) -> + Client = cfg(client, C), + PartyConfigRef = cfg(party_config_ref, C), + %% Step 1: Parent payment — saves token for prv(1)/trm(1) + Invoice1ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + Payment1Params = make_payment_params(?pmt_sys(<<"visa-ref">>)), + {ok, Payment1ID} = start_payment(Invoice1ID, Payment1Params, Client), + Payment1ID = await_payment_capture(Invoice1ID, Payment1ID, Client), + %% Get BCT from parent + #payproc_InvoicePayment{payment = #domain_InvoicePayment{payer = Payer1}} = + hg_client_invoicing:get_payment(Invoice1ID, Payment1ID, Client), + BCT = get_bank_card_token_from_payer(Payer1), + %% Step 2: Add tokens for prv(2) AND prv(3) — BankCard now has tokens for all three + _ = hg_customer_client:save_recurrent_token_by_card( + PartyConfigRef, + BCT, + {#domain_PaymentRoute{provider = ?prv(2), terminal = ?trm(2)}, <<"token-prv2">>} + ), + _ = hg_customer_client:save_recurrent_token_by_card( + PartyConfigRef, + BCT, + {#domain_PaymentRoute{provider = ?prv(3), terminal = ?trm(3)}, <<"token-prv3">>} + ), + %% Step 3: Routing only has prv(1) and prv(3) — prv(2) is NOT in routing rules. + %% prv(1) fails, so cascade should go to prv(3), skipping prv(2) despite its token. + _ = hg_domain:upsert(construct_routing_filter_fixture()), + %% Step 4: Recurrent payment + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + Payment2Params = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + {ok, Payment2ID} = start_payment(Invoice2ID, Payment2Params, Client), + Payment2ID = await_payment_capture(Invoice2ID, Payment2ID, Client), + %% Verify: routed to prv(3)/trm(3) — prv(2) was excluded by routing despite having a token + ?invoice_state( + ?invoice_w_status(?invoice_paid()), + [Payment2St] + ) = hg_client_invoicing:get(Invoice2ID, Client), + ?payment_state(?payment_w_status(Payment2ID, ?captured())) = Payment2St, + #domain_PaymentRoute{provider = ?prv(3), terminal = ?trm(3)} = + Payment2St#payproc_InvoicePayment.route. + make_payment_params(PmtSys) -> make_payment_params(true, undefined, PmtSys). @@ -403,7 +746,7 @@ make_payment_params(MakeRecurrent, RecurrentParent, PmtSys) -> make_payment_params(instant, MakeRecurrent, RecurrentParent, PmtSys). make_payment_params(FlowType, MakeRecurrent, RecurrentParent, PmtSys) -> - {PaymentTool, Session} = hg_dummy_provider:make_payment_tool(no_preauth, PmtSys), + {PaymentTool, Session} = make_unique_payment_tool(PmtSys), make_payment_params(PaymentTool, Session, FlowType, MakeRecurrent, RecurrentParent). make_payment_params(PaymentTool, Session, FlowType, MakeRecurrent, RecurrentParent) -> @@ -443,9 +786,15 @@ make_recurrent_payment_params(MakeRecurrent, RecurrentParent, PmtSys) -> make_recurrent_payment_params(instant, MakeRecurrent, RecurrentParent, PmtSys). make_recurrent_payment_params(FlowType, MakeRecurrent, RecurrentParent, PmtSys) -> - {PaymentTool, _Session} = hg_dummy_provider:make_payment_tool(no_preauth, PmtSys), + {PaymentTool, _Session} = make_unique_payment_tool(PmtSys), make_payment_params(undefined, PaymentTool, undefined, FlowType, MakeRecurrent, RecurrentParent). +make_unique_payment_tool(PmtSys) -> + {{bank_card, BCard}, Session} = hg_dummy_provider:make_payment_tool(no_preauth, PmtSys), + Suffix = integer_to_binary(erlang:unique_integer([positive])), + UniqueToken = <<(BCard#domain_BankCard.token)/binary, "/", Suffix/binary>>, + {{bank_card, BCard#domain_BankCard{token = UniqueToken}}, Session}. + make_due_date(LifetimeSeconds) -> genlib_time:unow() + LifetimeSeconds. @@ -753,3 +1102,200 @@ construct_domain_fixture(TermSet) -> hg_ct_fixture:construct_payment_system(?pmt_sys(<<"visa-ref">>), <<"visa payment system">>), hg_ct_fixture:construct_payment_system(?pmt_sys(<<"mastercard-ref">>), <<"mastercard payment system">>) ]. + +construct_cascade_fixture() -> + Revision = hg_domain:head(), + #domain_Provider{accounts = Accounts, terms = Terms} = + hg_domain:get(Revision, {provider, ?prv(1)}), + #domain_TermSetHierarchy{term_set = TermSet} = + hg_domain:get(Revision, {term_set_hierarchy, ?trms(1)}), + #domain_TermSet{payments = PaymentsTerms} = TermSet, + [ + %% Make ?prv(1) always fail (parent route will fail on child payment) + {provider, #domain_ProviderObject{ + ref = ?prv(1), + data = #domain_Provider{ + name = <<"Brovider (now failing)">>, + description = <<"Was good, now fails">>, + realm = test, + proxy = #domain_Proxy{ + ref = ?prx(1), + additional = #{ + <<"always_fail">> => <<"preauthorization_failed:card_blocked">>, + <<"override">> => <<"brovider_blocker">> + } + }, + accounts = Accounts, + terms = Terms + } + }}, + %% Succeeding provider for cascade fallback + {provider, #domain_ProviderObject{ + ref = ?prv(2), + data = #domain_Provider{ + name = <<"Cascade Fallback">>, + description = <<"Succeeds on cascade">>, + realm = test, + proxy = #domain_Proxy{ref = ?prx(1), additional = #{}}, + accounts = Accounts, + terms = Terms + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(2), + data = #domain_Terminal{ + name = <<"Cascade Fallback Terminal">>, + description = <<"Cascade Fallback Terminal">>, + provider_ref = ?prv(2) + } + }}, + %% Routing with both candidates + {routing_rules, #domain_RoutingRulesObject{ + ref = ?ruleset(2), + data = #domain_RoutingRuleset{ + name = <<"Cascade routing">>, + decisions = + {candidates, [ + ?candidate(<<"Brovider">>, {constant, true}, ?trm(1), 1000), + ?candidate(<<"Cascade Fallback">>, {constant, true}, ?trm(2), 1000) + ]} + } + }}, + %% Allow cascade: increase attempt limit to 2 + {term_set_hierarchy, #domain_TermSetHierarchyObject{ + ref = ?trms(1), + data = #domain_TermSetHierarchy{ + term_set = TermSet#domain_TermSet{ + payments = PaymentsTerms#domain_PaymentsServiceTerms{ + attempt_limit = {value, #domain_AttemptLimit{attempts = 2}} + } + } + } + }} + ]. + +construct_all_fail_fixture() -> + Revision = hg_domain:head(), + #domain_Provider{accounts = Accounts, terms = Terms} = + hg_domain:get(Revision, {provider, ?prv(1)}), + #domain_TermSetHierarchy{term_set = TermSet} = + hg_domain:get(Revision, {term_set_hierarchy, ?trms(1)}), + #domain_TermSet{payments = PaymentsTerms} = TermSet, + FailProxy = #{ + <<"always_fail">> => <<"preauthorization_failed:card_blocked">>, + <<"override">> => <<"all_fail">> + }, + [ + {provider, #domain_ProviderObject{ + ref = ?prv(1), + data = #domain_Provider{ + name = <<"Brovider (failing)">>, + description = <<"Always fails">>, + realm = test, + proxy = #domain_Proxy{ref = ?prx(1), additional = FailProxy}, + accounts = Accounts, + terms = Terms + } + }}, + {term_set_hierarchy, #domain_TermSetHierarchyObject{ + ref = ?trms(1), + data = #domain_TermSetHierarchy{ + term_set = TermSet#domain_TermSet{ + payments = PaymentsTerms#domain_PaymentsServiceTerms{ + attempt_limit = {value, #domain_AttemptLimit{attempts = 2}} + } + } + } + }} + ]. + +construct_routing_filter_fixture() -> + Revision = hg_domain:head(), + #domain_Provider{accounts = Accounts, terms = Terms} = + hg_domain:get(Revision, {provider, ?prv(1)}), + #domain_TermSetHierarchy{term_set = TermSet} = + hg_domain:get(Revision, {term_set_hierarchy, ?trms(1)}), + #domain_TermSet{payments = PaymentsTerms} = TermSet, + FailProxy = #{ + <<"always_fail">> => <<"preauthorization_failed:card_blocked">>, + <<"override">> => <<"routing_filter">> + }, + [ + %% prv(1): fails + {provider, #domain_ProviderObject{ + ref = ?prv(1), + data = #domain_Provider{ + name = <<"Failing provider">>, + description = <<"Always fails">>, + realm = test, + proxy = #domain_Proxy{ref = ?prx(1), additional = FailProxy}, + accounts = Accounts, + terms = Terms + } + }}, + %% prv(3): succeeds + {provider, #domain_ProviderObject{ + ref = ?prv(3), + data = #domain_Provider{ + name = <<"Fallback provider">>, + description = <<"Succeeds">>, + realm = test, + proxy = #domain_Proxy{ref = ?prx(1), additional = #{}}, + accounts = Accounts, + terms = Terms + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(3), + data = #domain_Terminal{ + name = <<"Fallback terminal">>, + description = <<"Fallback terminal">>, + provider_ref = ?prv(3) + } + }}, + %% Routing: only prv(1) and prv(3) — prv(2) is NOT routable + {routing_rules, #domain_RoutingRulesObject{ + ref = ?ruleset(2), + data = #domain_RoutingRuleset{ + name = <<"Filter routing">>, + decisions = + {candidates, [ + ?candidate(<<"Failing">>, {constant, true}, ?trm(1), 1000), + ?candidate(<<"Fallback">>, {constant, true}, ?trm(3), 1000) + ]} + } + }}, + %% Allow cascade: attempt limit 2 + {term_set_hierarchy, #domain_TermSetHierarchyObject{ + ref = ?trms(1), + data = #domain_TermSetHierarchy{ + term_set = TermSet#domain_TermSet{ + payments = PaymentsTerms#domain_PaymentsServiceTerms{ + attempt_limit = {value, #domain_AttemptLimit{attempts = 2}} + } + } + } + }} + ]. + +%% Restore only objects modified by cascade/fail fixtures, without touching proxy URLs. +%% construct_domain_fixture includes hg_ct_fixture:construct_proxy which sets url = <<>>, +%% but real proxy URLs were set by start_proxies in init_per_suite. +restore_domain_after_cascade() -> + TermSet = construct_term_set_w_recurrent_paytools(), + Fixture = construct_domain_fixture(TermSet), + SafeObjects = [Obj || {Type, _} = Obj <- Fixture, Type =/= proxy], + _ = hg_domain:upsert(SafeObjects), + ok. + +get_bank_card_token_from_payer( + ?payment_resource_payer( + #domain_DisposablePaymentResource{ + payment_tool = {bank_card, #domain_BankCard{token = Token}} + }, + _ + ) +) -> + Token; +get_bank_card_token_from_payer(?recurrent_payer({bank_card, #domain_BankCard{token = Token}}, _, _)) -> + Token. diff --git a/apps/hellgate/test/hg_dummy_provider.erl b/apps/hellgate/test/hg_dummy_provider.erl index d27cc611..ea0ab4a3 100644 --- a/apps/hellgate/test/hg_dummy_provider.erl +++ b/apps/hellgate/test/hg_dummy_provider.erl @@ -610,7 +610,11 @@ get_payment_tool_scenario({'crypto_currency', #domain_CryptoCurrencyRef{id = <<" get_payment_tool_scenario( {'mobile_commerce', #domain_MobileCommerce{operator = #domain_MobileOperatorRef{id = <<"mts-ref">>}}} ) -> - mobile_commerce. + mobile_commerce; +get_payment_tool_scenario({'bank_card', #domain_BankCard{token = Token} = BCard}) -> + %% Strip unique test suffix (e.g. <<"no_preauth/42">> -> <<"no_preauth">>) + [Base, _Suffix] = binary:split(Token, <<"/">>), + get_payment_tool_scenario({'bank_card', BCard#domain_BankCard{token = Base}}). -type tokenized_bank_card_payment_system() :: { diff --git a/apps/hg_proto/src/hg_proto.erl b/apps/hg_proto/src/hg_proto.erl index 29114695..2da3e413 100644 --- a/apps/hg_proto/src/hg_proto.erl +++ b/apps/hg_proto/src/hg_proto.erl @@ -41,7 +41,11 @@ get_service(fault_detector) -> get_service(limiter) -> {limproto_limiter_thrift, 'Limiter'}; get_service(party_config) -> - {dmsl_payproc_thrift, 'PartyManagement'}. + {dmsl_payproc_thrift, 'PartyManagement'}; +get_service(customer_management) -> + {dmsl_customer_thrift, 'CustomerManagement'}; +get_service(bank_card_storage) -> + {dmsl_customer_thrift, 'BankCardStorage'}. -spec get_service_spec(Name :: atom()) -> service_spec(). get_service_spec(Name) -> diff --git a/compose.yaml b/compose.yaml index 56a52afd..c2f82e64 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,6 +23,8 @@ services: condition: service_started bender: condition: service_healthy + cubasty: + condition: service_healthy working_dir: $PWD command: /sbin/init @@ -124,11 +126,25 @@ services: timeout: 5s retries: 10 + cubasty: + image: ghcr.io/valitydev/cubasty:sha-f94b6a0-epic-fix_matched + volumes: + - ./test/cubasty/sys.config:/opt/cs/releases/0.1/sys.config + hostname: cubasty + depends_on: + db: + condition: service_healthy + healthcheck: + test: "/opt/cs/bin/cs ping" + interval: 5s + timeout: 3s + retries: 20 + db: - image: postgres:15-bookworm + image: postgres:17 command: -c 'max_connections=1000' environment: - POSTGRES_MULTIPLE_DATABASES: "hellgate,bender,dmt,party_management,shumway,liminator" + POSTGRES_MULTIPLE_DATABASES: "hellgate,bender,dmt,party_management,shumway,liminator,customer_storage" POSTGRES_PASSWORD: "postgres" volumes: - ./test/postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d diff --git a/config/sys.config b/config/sys.config index 67cd24ed..e0d744d8 100644 --- a/config/sys.config +++ b/config/sys.config @@ -24,7 +24,8 @@ eventsink => "http://machinegun:8022/v1/event_sink", accounter => "http://shumway:8022/accounter", party_management => "http://party-management:8022/v1/processing/partymgmt", - customer_management => "http://hellgate:8022/v1/processing/customer_management", + customer_management => "http://cubasty:8022/v1/customer/management", + 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" diff --git a/rebar.config b/rebar.config index c506b56d..0030c39a 100644 --- a/rebar.config +++ b/rebar.config @@ -31,7 +31,7 @@ {gproc, "0.9.0"}, {genlib, {git, "https://github.com/valitydev/genlib.git", {tag, "v1.1.0"}}}, {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.1"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.27"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "BG-834/customer_payment"}}}, {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 f43cb6a7..40d4b0e3 100644 --- a/rebar.lock +++ b/rebar.lock @@ -27,7 +27,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"074ba8c024af7902ead5f24dc3d288c1922651c1"}}, + {ref,"e69d6da26261d6f030ac4c3f0ab0f89e2bd6df02"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git", diff --git a/test/cubasty/sys.config b/test/cubasty/sys.config new file mode 100644 index 00000000..a24eed27 --- /dev/null +++ b/test/cubasty/sys.config @@ -0,0 +1,37 @@ +[ + {cs, [ + {ip, "::"}, + {port, 8022}, + {default_woody_handling_timeout, 30000}, + {epg_db_name, cs}, + {health_check, #{ + service => {erl_health, service, [<<"customer-storage">>]} + }} + ]}, + + {epg_connector, [ + {databases, #{ + cs => #{ + host => "db", + port => 5432, + username => "postgres", + password => "postgres", + database => "customer_storage" + } + }}, + {pools, #{ + default_pool => #{ + database => cs, + size => 10 + } + }} + ]}, + + {scoper, [ + {storage, scoper_storage_logger} + ]}, + + {prometheus, [ + {collectors, [default]} + ]} +].