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..b0035532 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +rebar 3.24.0 +erlang 27.1.2 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..a1f10ee6 --- /dev/null +++ b/apps/hellgate/src/hg_customer_client.erl @@ -0,0 +1,122 @@ +-module(hg_customer_client). + +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). + +-export([create_customer/1]). +-export([get_by_parent_payment/2]). +-export([get_recurrent_tokens/2]). +-export([tokens_to_map/1]). +-export([add_payment/3]). +-export([save_recurrent_token/4]). + +-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()}. + +%% + +-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 tokens_to_map([recurrent_token()]) -> cascade_tokens(). +tokens_to_map(Tokens) -> + lists:foldl(fun token_to_map_entry/2, #{}, Tokens). + +-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 save_recurrent_token( + dmsl_customer_thrift:'CustomerID'(), + token(), + dmsl_domain_thrift:'PaymentRoute'(), + token() +) -> ok. +save_recurrent_token( + CustomerID, + BankCardToken, + #domain_PaymentRoute{provider = ProviderRef, terminal = TerminalRef}, + RecToken +) -> + {ok, #customer_BankCard{id = BankCardID}} = call( + customer_management, + 'AddBankCard', + {CustomerID, #customer_BankCardParams{bank_card_token = BankCardToken}} + ), + {ok, _} = call( + bank_card_storage, + 'AddRecurrentToken', + {#customer_RecurrentTokenParams{ + bank_card_id = BankCardID, + provider_ref = ProviderRef, + terminal_ref = TerminalRef, + token = RecToken + }} + ), + ok. + +%% + +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 + ). + +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}. diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 26874612..5b150004 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,40 @@ 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 {InheritedCustomerID, PayerParams} of + {CID, {recurrent, #payproc_RecurrentPayerParams{recurrent_parent = ?recurrent_parent(InvID, PmtID)}}} when + CID =/= undefined + -> + case hg_customer_client:get_recurrent_tokens(InvID, PmtID) of + [_ | _] = Tokens -> [?cascade_tokens_loaded(Tokens)]; + [] -> [] + end; + _ -> + [] + end, + Events = [?payment_started(Payment2)] ++ CascadeTokenEvents, {collapse_changes(Events, undefined, #{}), {Events, hg_machine_action:instant()}}. +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 +1941,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 +2025,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 +2077,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 +2406,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 +2466,43 @@ check_recurrent_token(#st{ check_recurrent_token(_) -> ok. +maybe_save_recurrent_token_to_customer( + #st{ + payment = #domain_InvoicePayment{ + id = PaymentID, + customer_id = CustomerID, + make_recurrent = MakeRecurrent, + 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), + case {MakeRecurrent, RecToken, get_bank_card_token(Payer)} of + {true, RT, BCT} when RT =/= undefined, BCT =/= undefined -> + Route = get_route(St), + hg_customer_client:save_recurrent_token(CustomerID, BCT, Route, RT); + _ -> + ok + end; +maybe_save_recurrent_token_to_customer(_St) -> + ok. + +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 +2893,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 +2925,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 +2934,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 +2944,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 +3248,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..3d9a2ce7 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,12 @@ -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]). %% Internal types @@ -60,6 +69,7 @@ init([]) -> all() -> [ {group, basic_operations}, + {group, cascade_tokens}, {group, domain_affecting_operations} ]. @@ -79,6 +89,14 @@ 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 ]} ]. @@ -162,6 +180,9 @@ init_per_testcase(Name, C) -> ]. -spec end_per_testcase(test_case_name(), config()) -> ok. +end_per_testcase(cascade_recurrent_payment_success_test, _C) -> + _ = hg_domain:upsert(construct_domain_fixture(construct_term_set_w_recurrent_paytools())), + ok; end_per_testcase(_Name, _C) -> ok. @@ -396,6 +417,153 @@ 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 + [#customer_RecurrentToken{} = ParentToken] = + hg_customer_client:get_recurrent_tokens(Invoice1ID, Payment1ID), + hg_customer_client:save_recurrent_token( + CustomerID, + ParentToken#customer_RecurrentToken.token, + #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. + make_payment_params(PmtSys) -> make_payment_params(true, undefined, PmtSys). @@ -753,3 +921,74 @@ 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}} + } + } + } + }} + ]. 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..fe68cf6b 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-a1a945f-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]} + ]} +].