From 057c29dc9c63308893fb1adb2b4acac54cf247d3 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 20 Mar 2026 00:59:23 +0300 Subject: [PATCH 1/9] BG-834: Recurrent cascade --- .tool-versions | 2 + CLAUDE.md | 81 +++++++++++++++++++ apps/hellgate/include/hg_invoice_payment.hrl | 2 + apps/hellgate/src/hg_customer_client.erl | 53 ++++++++++++ apps/hellgate/src/hg_invoice_payment.erl | 69 ++++++++++++++-- apps/hellgate/test/hg_ct_helper.erl | 4 +- .../test/hg_direct_recurrent_tests_SUITE.erl | 81 +++++++++++++++++++ apps/hg_proto/src/hg_proto.erl | 6 +- compose.yaml | 20 ++++- config/sys.config | 3 +- rebar.config | 2 +- rebar.lock | 2 +- test/cubasty/sys.config | 37 +++++++++ 13 files changed, 347 insertions(+), 15 deletions(-) create mode 100644 .tool-versions create mode 100644 CLAUDE.md create mode 100644 apps/hellgate/src/hg_customer_client.erl create mode 100644 test/cubasty/sys.config 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..fee35c1e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Hellgate is the core payment processing state machine service. It orchestrates invoice lifecycle, payment processing, refunds, chargebacks, and provider routing using event sourcing (Progressor/Machinegun backends) and Woody/Thrift RPC. + +## Build & Development Commands + +```bash +make compile # Build the project +make test # Run eunit + common test +make eunit # Unit tests only +make common-test # All Common Test suites +make common-test.invoice # Single suite (hg_invoice_tests_SUITE) +make common-test.invoice CT_CASE=some_case # Single test case +make dialyze # Dialyzer type checking (runs as test profile) +make lint # Elvis code style checks +make format # Auto-format with erlfmt +make check-format # Verify formatting +make xref # Cross-reference analysis +make release # Production release + +# Docker-based (full dependency stack): +make wdeps-test # Tests with Postgres, DMT, party-management, etc. +make wdeps-common-test.invoice # Single suite with deps +make wdeps-common-test.invoice CT_CASE=some_case # Single case with deps +``` + +Common Test suites require external services (Postgres, DMT, party-management, bender, limiter, shumway, cubasty). Use `wdeps-` prefix to run them with Docker Compose. + +Suite naming pattern: `make common-test.X` maps to `apps/hellgate/test/hg_X_tests_SUITE.erl`. + +## Architecture + +### OTP Applications (in `apps/`) + +- **hellgate** - Main app: invoice/payment state machines, routing, limits, accounting, provider proxying +- **hg_proto** - Thrift service definitions and protocol wrappers +- **hg_client** - Woody client library for invoicing/templating APIs +- **hg_progressor** - Progressor backend integration with OpenTelemetry tracing +- **routing** - Payment routing logic (provider/terminal selection, fault detection) + +### Core State Machine Hierarchy + +`hg_invoice` (invoice lifecycle) -> `hg_invoice_payment` (payment processing) -> `hg_invoice_payment_refund`, `hg_invoice_payment_chargeback` + +All machines are event-sourced via `hg_machine` behavior, backed by Progressor (default) or Machinegun. + +### Key Modules + +- `hg_machine.erl` - Machine abstraction behavior (signal/call handling, event history) +- `hg_invoice.erl` - Invoice state machine +- `hg_invoice_payment.erl` - Payment state machine (largest module: sessions, retries, routing, capture, refund, chargeback) +- `hg_routing.erl` (in routing app) - Route gathering, provider selection, fault detector integration +- `hg_limiter.erl` - Turnover limit enforcement (hold/commit/rollback) +- `hg_cashflow.erl` - Cash flow computation and finalization +- `hg_session.erl` - Provider interaction session management +- `hg_inspector.erl` - Risk scoring and blacklist checking + +### External Service Dependencies + +Party-management (merchant config), DMT (domain/business rules), Bender (ID generation), Limiter/Liminator (rate limits), Shumway (accounting), Cubasty (customer storage), Fault Detector (provider availability). + +## Erlang Conventions + +- **Compiler flags**: `warnings_as_errors`, `warn_missing_spec` - all exported functions need typespecs +- **Formatter**: erlfmt, 120 char width. Run `make format` before committing +- **Linter**: Elvis with strict rules - no `if` expressions, max nesting level 4, max arity 10 +- **Dialyzer**: Runs under `test` profile (`rebar3 as test dialyzer`) +- **Validation sequence**: `make compile && make format && make lint && make dialyze` + +## Testing + +Test helpers live alongside suites in `apps/hellgate/test/`: +- `hg_ct_helper.erl` - CT setup, service startup, context/config creation +- `hg_ct_fixture.erl` - Domain fixture generation +- `hg_invoice_helper.erl` - Invoice/payment test utilities +- `hg_dummy_provider.erl` - Mock payment provider +- `hg_dummy_inspector.erl` - Mock risk inspector diff --git a/apps/hellgate/include/hg_invoice_payment.hrl b/apps/hellgate/include/hg_invoice_payment.hrl index fa4ae914..88fbf058 100644 --- a/apps/hellgate/include/hg_invoice_payment.hrl +++ b/apps/hellgate/include/hg_invoice_payment.hrl @@ -23,6 +23,8 @@ 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 | #{dmsl_customer_thrift:'ProviderTerminalKey'() => dmsl_domain_thrift:'Token'()}, 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/src/hg_customer_client.erl b/apps/hellgate/src/hg_customer_client.erl new file mode 100644 index 00000000..b851bbd5 --- /dev/null +++ b/apps/hellgate/src/hg_customer_client.erl @@ -0,0 +1,53 @@ +-module(hg_customer_client). + +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). + +-export([get_cascade_tokens/2]). + +-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 cascade_tokens() :: #{provider_terminal_key() => token()}. + +%% + +-spec get_cascade_tokens(invoice_id(), payment_id()) -> cascade_tokens(). +get_cascade_tokens(InvoiceID, PaymentID) -> + try + get_cascade_tokens_(InvoiceID, PaymentID) + catch + error:_ -> #{} + end. + +get_cascade_tokens_(InvoiceID, PaymentID) -> + case hg_woody_wrapper:call(customer_management, 'GetByParentPayment', {InvoiceID, PaymentID}) of + {ok, #customer_CustomerState{bank_card_refs = BankCardRefs}} -> + lists:foldl(fun collect_bank_card_tokens/2, #{}, BankCardRefs); + {exception, #customer_CustomerNotFound{}} -> + #{}; + {exception, #customer_InvalidRecurrentParent{}} -> + #{} + end. + +collect_bank_card_tokens(#customer_BankCardRef{id = BankCardID}, Acc) -> + case hg_woody_wrapper:call(bank_card_storage, 'GetRecurrentTokens', {BankCardID}) of + {ok, Tokens} -> + lists:foldl(fun collect_recurrent_token/2, Acc, Tokens); + {exception, _} -> + Acc + end. + +collect_recurrent_token( + #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..b5c82f4e 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), @@ -438,10 +440,22 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> payer_session_info = PayerSessionInfo, context = Context, external_id = ExternalID, - processing_deadline = Deadline + processing_deadline = Deadline, + customer_id = CustomerID }, + CascadeTokens = + case PayerParams of + {recurrent, #payproc_RecurrentPayerParams{recurrent_parent = ?recurrent_parent(InvID, PmtID)}} -> + case hg_customer_client:get_cascade_tokens(InvID, PmtID) of + T when map_size(T) > 0 -> T; + _ -> undefined + end; + _ -> + undefined + end, Events = [?payment_started(Payment2)], - {collapse_changes(Events, undefined, #{}), {Events, hg_machine_action:instant()}}. + InitSt = #st{activity = {payment, new}, cascade_recurrent_tokens = CascadeTokens}, + {collapse_changes(Events, InitSt, #{}), {Events, hg_machine_action:instant()}}. get_merchant_payments_terms(Opts, Revision, _Timestamp, VS) -> Shop = get_shop(Opts, Revision), @@ -1918,6 +1932,7 @@ run_routing_decision_pipeline(Ctx0, VS, St) -> Ctx0, [ fun(Ctx) -> filter_attempted_routes(Ctx, St) end, + fun(Ctx) -> filter_routes_by_recurrent_tokens(Ctx, St) end, 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, @@ -2022,6 +2037,29 @@ 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}) when map_size(Tokens) =:= 0 -> + 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). @@ -2779,7 +2817,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 +2849,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 +2858,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 +2868,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{ 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..a1039d5e 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -7,6 +7,8 @@ -include("invoice_events.hrl"). -include("payment_events.hrl"). +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). + -export([init_per_suite/1]). -export([end_per_suite/1]). @@ -27,6 +29,8 @@ -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([cascade_tokens_filter_success_test/1]). %% Internal types @@ -60,6 +64,7 @@ init([]) -> all() -> [ {group, basic_operations}, + {group, cascade_tokens}, {group, domain_affecting_operations} ]. @@ -79,6 +84,10 @@ groups() -> ]}, {domain_affecting_operations, [], [ not_permitted_recurrent_test + ]}, + {cascade_tokens, [], [ + customer_id_stored_test, + cascade_tokens_filter_success_test ]} ]. @@ -151,6 +160,9 @@ end_per_group(_Name, _C) -> -spec init_per_testcase(test_case_name(), config()) -> config(). init_per_testcase(Name, C) -> + init_per_testcase(default, Name, C). + +init_per_testcase(default, Name, C) -> TraceID = hg_ct_helper:make_trace_id(Name), ApiClient = hg_ct_helper:create_client(cfg(root_url, C)), Client = hg_client_invoicing:start_link(ApiClient), @@ -396,6 +408,75 @@ 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), + 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), + Invoice2ID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C), + CustomerID = <<"test-customer-42">>, + RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), + BaseParams = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), + Payment2Params = BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerID}, + {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), + CustomerID = StoredCustomerID. + +-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), + %% Establish parent payment + 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), + %% Seed cubasty: create customer, link bank card + recurrent token, link parent payment + ok = hg_context:save(hg_context:create()), + {ok, #customer_Customer{id = CustID}} = hg_woody_wrapper:call( + customer_management, + 'Create', + {#customer_CustomerParams{party_ref = PartyConfigRef}} + ), + {ok, #customer_BankCard{id = BankCardID}} = hg_woody_wrapper:call( + customer_management, + 'AddBankCard', + {CustID, #customer_BankCardParams{bank_card_token = <<"test-cascade-visa-token">>}} + ), + {ok, _} = hg_woody_wrapper:call( + bank_card_storage, + 'AddRecurrentToken', + {#customer_RecurrentTokenParams{ + bank_card_id = BankCardID, + provider_ref = ?prv(1), + terminal_ref = ?trm(1), + token = <<"cascade-token-1">> + }} + ), + {ok, ok} = hg_woody_wrapper:call( + customer_management, + 'AddPayment', + {CustID, Invoice1ID, Payment1ID} + ), + ok = hg_context:cleanup(), + %% Second recurrent payment: hellgate queries cubasty, gets token for ?prv(1)/?trm(1) + 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). + make_payment_params(PmtSys) -> make_payment_params(true, undefined, PmtSys). 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..d9437bba 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-048a791 + 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 f3169a24..e679fcbb 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 0182ce56..f9e002ae 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,"c187a42a9e0e93e2832b3d5e29d7ab572cb26988"}}, 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]} + ]} +]. From e871a93dfec6d43dbed3cb8c2c077026cee9ea11 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 20 Mar 2026 17:07:39 +0300 Subject: [PATCH 2/9] Fixes --- .gitignore | 2 + CLAUDE.md | 81 -------- apps/hellgate/include/hg_invoice_payment.hrl | 3 +- apps/hellgate/include/payment_events.hrl | 4 + apps/hellgate/src/hg_customer_client.erl | 108 ++++++++-- apps/hellgate/src/hg_invoice_payment.erl | 129 ++++++++++-- .../test/hg_direct_recurrent_tests_SUITE.erl | 187 ++++++++++++++---- compose.yaml | 2 +- rebar.lock | 2 +- 9 files changed, 354 insertions(+), 164 deletions(-) delete mode 100644 CLAUDE.md 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/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index fee35c1e..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Hellgate is the core payment processing state machine service. It orchestrates invoice lifecycle, payment processing, refunds, chargebacks, and provider routing using event sourcing (Progressor/Machinegun backends) and Woody/Thrift RPC. - -## Build & Development Commands - -```bash -make compile # Build the project -make test # Run eunit + common test -make eunit # Unit tests only -make common-test # All Common Test suites -make common-test.invoice # Single suite (hg_invoice_tests_SUITE) -make common-test.invoice CT_CASE=some_case # Single test case -make dialyze # Dialyzer type checking (runs as test profile) -make lint # Elvis code style checks -make format # Auto-format with erlfmt -make check-format # Verify formatting -make xref # Cross-reference analysis -make release # Production release - -# Docker-based (full dependency stack): -make wdeps-test # Tests with Postgres, DMT, party-management, etc. -make wdeps-common-test.invoice # Single suite with deps -make wdeps-common-test.invoice CT_CASE=some_case # Single case with deps -``` - -Common Test suites require external services (Postgres, DMT, party-management, bender, limiter, shumway, cubasty). Use `wdeps-` prefix to run them with Docker Compose. - -Suite naming pattern: `make common-test.X` maps to `apps/hellgate/test/hg_X_tests_SUITE.erl`. - -## Architecture - -### OTP Applications (in `apps/`) - -- **hellgate** - Main app: invoice/payment state machines, routing, limits, accounting, provider proxying -- **hg_proto** - Thrift service definitions and protocol wrappers -- **hg_client** - Woody client library for invoicing/templating APIs -- **hg_progressor** - Progressor backend integration with OpenTelemetry tracing -- **routing** - Payment routing logic (provider/terminal selection, fault detection) - -### Core State Machine Hierarchy - -`hg_invoice` (invoice lifecycle) -> `hg_invoice_payment` (payment processing) -> `hg_invoice_payment_refund`, `hg_invoice_payment_chargeback` - -All machines are event-sourced via `hg_machine` behavior, backed by Progressor (default) or Machinegun. - -### Key Modules - -- `hg_machine.erl` - Machine abstraction behavior (signal/call handling, event history) -- `hg_invoice.erl` - Invoice state machine -- `hg_invoice_payment.erl` - Payment state machine (largest module: sessions, retries, routing, capture, refund, chargeback) -- `hg_routing.erl` (in routing app) - Route gathering, provider selection, fault detector integration -- `hg_limiter.erl` - Turnover limit enforcement (hold/commit/rollback) -- `hg_cashflow.erl` - Cash flow computation and finalization -- `hg_session.erl` - Provider interaction session management -- `hg_inspector.erl` - Risk scoring and blacklist checking - -### External Service Dependencies - -Party-management (merchant config), DMT (domain/business rules), Bender (ID generation), Limiter/Liminator (rate limits), Shumway (accounting), Cubasty (customer storage), Fault Detector (provider availability). - -## Erlang Conventions - -- **Compiler flags**: `warnings_as_errors`, `warn_missing_spec` - all exported functions need typespecs -- **Formatter**: erlfmt, 120 char width. Run `make format` before committing -- **Linter**: Elvis with strict rules - no `if` expressions, max nesting level 4, max arity 10 -- **Dialyzer**: Runs under `test` profile (`rebar3 as test dialyzer`) -- **Validation sequence**: `make compile && make format && make lint && make dialyze` - -## Testing - -Test helpers live alongside suites in `apps/hellgate/test/`: -- `hg_ct_helper.erl` - CT setup, service startup, context/config creation -- `hg_ct_fixture.erl` - Domain fixture generation -- `hg_invoice_helper.erl` - Invoice/payment test utilities -- `hg_dummy_provider.erl` - Mock payment provider -- `hg_dummy_inspector.erl` - Mock risk inspector diff --git a/apps/hellgate/include/hg_invoice_payment.hrl b/apps/hellgate/include/hg_invoice_payment.hrl index 88fbf058..78eb97a7 100644 --- a/apps/hellgate/include/hg_invoice_payment.hrl +++ b/apps/hellgate/include/hg_invoice_payment.hrl @@ -23,8 +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 | #{dmsl_customer_thrift:'ProviderTerminalKey'() => 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 index b851bbd5..8760d972 100644 --- a/apps/hellgate/src/hg_customer_client.erl +++ b/apps/hellgate/src/hg_customer_client.erl @@ -1,44 +1,110 @@ -module(hg_customer_client). -include_lib("damsel/include/dmsl_customer_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). --export([get_cascade_tokens/2]). +-export([create_customer/1]). +-export([get_recurrent_tokens/2]). +-export([tokens_to_map/1]). +-export([save_recurrent_token/6]). + +-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 get_cascade_tokens(invoice_id(), payment_id()) -> cascade_tokens(). -get_cascade_tokens(InvoiceID, PaymentID) -> - try - get_cascade_tokens_(InvoiceID, PaymentID) - catch - error:_ -> #{} - end. +-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. -get_cascade_tokens_(InvoiceID, PaymentID) -> - case hg_woody_wrapper:call(customer_management, 'GetByParentPayment', {InvoiceID, PaymentID}) of +-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:foldl(fun collect_bank_card_tokens/2, #{}, BankCardRefs); + lists:flatmap(fun collect_bank_card_tokens/1, BankCardRefs); {exception, #customer_CustomerNotFound{}} -> - #{}; + []; {exception, #customer_InvalidRecurrentParent{}} -> - #{} + [] end. -collect_bank_card_tokens(#customer_BankCardRef{id = BankCardID}, Acc) -> - case hg_woody_wrapper:call(bank_card_storage, 'GetRecurrentTokens', {BankCardID}) of - {ok, Tokens} -> - lists:foldl(fun collect_recurrent_token/2, Acc, Tokens); - {exception, _} -> - Acc - end. +-spec tokens_to_map([recurrent_token()]) -> cascade_tokens(). +tokens_to_map(Tokens) -> + lists:foldl(fun token_to_map_entry/2, #{}, Tokens). + +-spec save_recurrent_token( + dmsl_customer_thrift:'CustomerID'(), + token(), + dmsl_domain_thrift:'PaymentRoute'(), + token(), + invoice_id(), + payment_id() +) -> ok. +save_recurrent_token( + CustomerID, + BankCardToken, + #domain_PaymentRoute{provider = ProviderRef, terminal = TerminalRef}, + RecToken, + InvoiceID, + PaymentID +) -> + {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, ok} = call( + customer_management, + 'AddPayment', + {CustomerID, InvoiceID, PaymentID} + ), + 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. -collect_recurrent_token( +token_to_map_entry( #customer_RecurrentToken{ provider_ref = ProviderRef, terminal_ref = TerminalRef, diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index b5c82f4e..945a00a6 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -436,26 +436,46 @@ 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, - customer_id = CustomerID + customer_id = InheritedCustomerID }, - CascadeTokens = - case PayerParams of - {recurrent, #payproc_RecurrentPayerParams{recurrent_parent = ?recurrent_parent(InvID, PmtID)}} -> - case hg_customer_client:get_cascade_tokens(InvID, PmtID) of - T when map_size(T) > 0 -> T; - _ -> undefined - end; + CascadeTokenEvents = + case {InheritedCustomerID, PayerParams, VS0} of + {CID, {recurrent, #payproc_RecurrentPayerParams{recurrent_parent = ?recurrent_parent(InvID, PmtID)}}, #{ + parent_payment := ParentSt + }} when CID =/= undefined -> + CubastyTokens = hg_customer_client:get_recurrent_tokens(InvID, PmtID), + ParentToken = make_parent_recurrent_token(ParentSt), + AllTokens = [ParentToken | CubastyTokens], + [?cascade_tokens_loaded(AllTokens)]; _ -> - undefined + [] end, - Events = [?payment_started(Payment2)], - InitSt = #st{activity = {payment, new}, cascade_recurrent_tokens = CascadeTokens}, - {collapse_changes(Events, InitSt, #{}), {Events, hg_machine_action:instant()}}. + Events = [?payment_started(Payment2)] ++ CascadeTokenEvents, + {collapse_changes(Events, undefined, #{}), {Events, hg_machine_action:instant()}}. + +make_parent_recurrent_token(ParentSt) -> + #domain_PaymentRoute{provider = ProviderRef, terminal = TerminalRef} = get_route(ParentSt), + RecToken = get_recurrent_token(ParentSt), + #domain_InvoicePayment{created_at = CreatedAt} = get_payment(ParentSt), + #customer_RecurrentToken{ + id = <<"parent">>, + provider_ref = ProviderRef, + terminal_ref = TerminalRef, + token = RecToken, + created_at = CreatedAt, + status = {active, #customer_RecurrentTokenActive{}} + }. + +maybe_inherit_customer_id(undefined, #{parent_payment := ParentPayment}) -> + (get_payment(ParentPayment))#domain_InvoicePayment.customer_id; +maybe_inherit_customer_id(CustomerID, _VS) -> + CustomerID. get_merchant_payments_terms(Opts, Revision, _Timestamp, VS) -> Shop = get_shop(Opts, Revision), @@ -1928,19 +1948,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, - fun(Ctx) -> filter_routes_by_recurrent_tokens(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 ] ). +%% With cascade tokens: skip token filter (parent route forced on first attempt, +%% fallback routes use parent's token on retries), keep all candidates for cascade viability. +%% First attempt: force parent route. Retries: normal priority-based 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}) when Tokens =/= undefined -> + {fun(Ctx) -> Ctx 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 @@ -1995,6 +2032,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 @@ -2039,8 +2086,6 @@ filter_attempted_routes(Ctx, #st{routes = AttemptedRoutes}) -> filter_routes_by_recurrent_tokens(Ctx, #st{cascade_recurrent_tokens = undefined}) -> Ctx; -filter_routes_by_recurrent_tokens(Ctx, #st{cascade_recurrent_tokens = Tokens}) when map_size(Tokens) =:= 0 -> - Ctx; filter_routes_by_recurrent_tokens(Ctx, #st{cascade_recurrent_tokens = Tokens}) -> lists:foldl( fun(Route, C) -> @@ -2368,6 +2413,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}}. @@ -2427,6 +2473,44 @@ check_recurrent_token(#st{ check_recurrent_token(_) -> ok. +maybe_save_recurrent_token_to_customer( + #st{ + payment = #domain_InvoicePayment{ + id = PaymentID, + make_recurrent = true, + customer_id = CustomerID, + payer = Payer + }, + recurrent_token = RecToken + } = St +) when CustomerID =/= undefined, RecToken =/= undefined -> + case get_bank_card_token(Payer) of + undefined -> + ok; + BankCardToken -> + Route = get_route(St), + InvoiceID = get_invoice_id(get_invoice(get_opts(St))), + hg_customer_client:save_recurrent_token( + CustomerID, BankCardToken, Route, RecToken, InvoiceID, PaymentID + ) + 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) -> @@ -2871,7 +2955,7 @@ construct_proxy_payment( construct_payment_resource(?payment_resource_payer(Resource, _), _St) -> {disposable_payment_resource, Resource}; construct_payment_resource( - ?recurrent_payer(PaymentTool, ?recurrent_parent(_InvoiceID, _PaymentID), _), + ?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), @@ -2879,7 +2963,14 @@ construct_payment_resource( provider_ref = ProviderRef, terminal_ref = TerminalRef }, - RecToken = maps:get(Key, Tokens), + RecToken = + case maps:find(Key, Tokens) of + {ok, T} -> + T; + error -> + %% Cascade route without pre-existing token — use parent's token + get_recurrent_token(get_payment_state(InvoiceID, PaymentID)) + end, {recurrent_payment_resource, #proxy_provider_RecurrentPaymentResource{ payment_tool = PaymentTool, rec_token = RecToken @@ -3172,6 +3263,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_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index a1039d5e..89868e1c 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -8,6 +8,7 @@ -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]). @@ -30,7 +31,9 @@ -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([cascade_tokens_filter_success_test/1]). +-export([cascade_recurrent_payment_success_test/1]). %% Internal types @@ -87,7 +90,9 @@ groups() -> ]}, {cascade_tokens, [], [ customer_id_stored_test, - cascade_tokens_filter_success_test + customer_id_stored_no_parent_test, + cascade_tokens_filter_success_test, + cascade_recurrent_payment_success_test ]} ]. @@ -160,9 +165,6 @@ end_per_group(_Name, _C) -> -spec init_per_testcase(test_case_name(), config()) -> config(). init_per_testcase(Name, C) -> - init_per_testcase(default, Name, C). - -init_per_testcase(default, Name, C) -> TraceID = hg_ct_helper:make_trace_id(Name), ApiClient = hg_ct_helper:create_client(cfg(root_url, C)), Client = hg_client_invoicing:start_link(ApiClient), @@ -174,6 +176,9 @@ init_per_testcase(default, 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. @@ -413,60 +418,54 @@ construct_proxy(ID, Url, Options) -> -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), - Payment1Params = make_payment_params(?pmt_sys(<<"visa-ref">>)), + 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), - CustomerID = <<"test-customer-42">>, RecurrentParent = ?recurrent_parent(Invoice1ID, Payment1ID), - BaseParams = make_recurrent_payment_params(true, RecurrentParent, ?pmt_sys(<<"visa-ref">>)), - Payment2Params = BaseParams#payproc_InvoicePaymentParams{customer_id = CustomerID}, + 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), - CustomerID = StoredCustomerID. + ?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 cascade_tokens_filter_success_test(config()) -> test_result(). cascade_tokens_filter_success_test(C) -> Client = cfg(client, C), PartyConfigRef = cfg(party_config_ref, C), - %% Establish parent payment + #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), - Payment1Params = make_payment_params(?pmt_sys(<<"visa-ref">>)), + 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), - %% Seed cubasty: create customer, link bank card + recurrent token, link parent payment - ok = hg_context:save(hg_context:create()), - {ok, #customer_Customer{id = CustID}} = hg_woody_wrapper:call( - customer_management, - 'Create', - {#customer_CustomerParams{party_ref = PartyConfigRef}} - ), - {ok, #customer_BankCard{id = BankCardID}} = hg_woody_wrapper:call( - customer_management, - 'AddBankCard', - {CustID, #customer_BankCardParams{bank_card_token = <<"test-cascade-visa-token">>}} - ), - {ok, _} = hg_woody_wrapper:call( - bank_card_storage, - 'AddRecurrentToken', - {#customer_RecurrentTokenParams{ - bank_card_id = BankCardID, - provider_ref = ?prv(1), - terminal_ref = ?trm(1), - token = <<"cascade-token-1">> - }} - ), - {ok, ok} = hg_woody_wrapper:call( - customer_management, - 'AddPayment', - {CustID, Invoice1ID, Payment1ID} - ), - ok = hg_context:cleanup(), - %% Second recurrent payment: hellgate queries cubasty, gets token for ?prv(1)/?trm(1) + %% 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">>)), @@ -477,6 +476,43 @@ cascade_tokens_filter_success_test(C) -> [?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 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). @@ -834,3 +870,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/compose.yaml b/compose.yaml index d9437bba..fe68cf6b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -127,7 +127,7 @@ services: retries: 10 cubasty: - image: ghcr.io/valitydev/cubasty:sha-048a791 + image: ghcr.io/valitydev/cubasty:sha-a1a945f-epic-fix_matched volumes: - ./test/cubasty/sys.config:/opt/cs/releases/0.1/sys.config hostname: cubasty diff --git a/rebar.lock b/rebar.lock index 576bd149..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,"c187a42a9e0e93e2832b3d5e29d7ab572cb26988"}}, + {ref,"e69d6da26261d6f030ac4c3f0ab0f89e2bd6df02"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git", From c0d2fd03ff4d12ca2b78a30b952319df148f628d Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 20 Mar 2026 19:47:14 +0300 Subject: [PATCH 3/9] Fixes --- apps/hellgate/src/hg_invoice_payment.erl | 42 +++++++------------ .../test/hg_direct_recurrent_tests_SUITE.erl | 23 ++++++++++ 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 945a00a6..b2412276 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -445,35 +445,28 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> customer_id = InheritedCustomerID }, CascadeTokenEvents = - case {InheritedCustomerID, PayerParams, VS0} of - {CID, {recurrent, #payproc_RecurrentPayerParams{recurrent_parent = ?recurrent_parent(InvID, PmtID)}}, #{ - parent_payment := ParentSt - }} when CID =/= undefined -> - CubastyTokens = hg_customer_client:get_recurrent_tokens(InvID, PmtID), - ParentToken = make_parent_recurrent_token(ParentSt), - AllTokens = [ParentToken | CubastyTokens], - [?cascade_tokens_loaded(AllTokens)]; + 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()}}. -make_parent_recurrent_token(ParentSt) -> - #domain_PaymentRoute{provider = ProviderRef, terminal = TerminalRef} = get_route(ParentSt), - RecToken = get_recurrent_token(ParentSt), - #domain_InvoicePayment{created_at = CreatedAt} = get_payment(ParentSt), - #customer_RecurrentToken{ - id = <<"parent">>, - provider_ref = ProviderRef, - terminal_ref = TerminalRef, - token = RecToken, - created_at = CreatedAt, - status = {active, #customer_RecurrentTokenActive{}} - }. - 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. @@ -2965,11 +2958,8 @@ construct_payment_resource( }, RecToken = case maps:find(Key, Tokens) of - {ok, T} -> - T; - error -> - %% Cascade route without pre-existing token — use parent's token - get_recurrent_token(get_payment_state(InvoiceID, PaymentID)) + {ok, T} -> T; + error -> get_recurrent_token(get_payment_state(InvoiceID, PaymentID)) end, {recurrent_payment_resource, #proxy_provider_RecurrentPaymentResource{ payment_tool = PaymentTool, diff --git a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index 89868e1c..ef9c96aa 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -34,6 +34,7 @@ -export([customer_id_stored_no_parent_test/1]). -export([cascade_tokens_filter_success_test/1]). -export([cascade_recurrent_payment_success_test/1]). +-export([different_customer_id_test/1]). %% Internal types @@ -91,6 +92,7 @@ groups() -> {cascade_tokens, [], [ customer_id_stored_test, customer_id_stored_no_parent_test, + different_customer_id_test, cascade_tokens_filter_success_test, cascade_recurrent_payment_success_test ]} @@ -452,6 +454,27 @@ customer_id_stored_no_parent_test(C) -> } = 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 cascade_tokens_filter_success_test(config()) -> test_result(). cascade_tokens_filter_success_test(C) -> Client = cfg(client, C), From 04f5e64405086dcf035e674a56b05665f1f2d103 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 20 Mar 2026 20:03:28 +0300 Subject: [PATCH 4/9] Fix --- apps/hellgate/src/hg_customer_client.erl | 27 ++++++++++--------- apps/hellgate/src/hg_invoice_payment.erl | 19 +++++++------ .../test/hg_direct_recurrent_tests_SUITE.erl | 19 +++++++++++++ 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/apps/hellgate/src/hg_customer_client.erl b/apps/hellgate/src/hg_customer_client.erl index 8760d972..a1f10ee6 100644 --- a/apps/hellgate/src/hg_customer_client.erl +++ b/apps/hellgate/src/hg_customer_client.erl @@ -4,9 +4,11 @@ -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([save_recurrent_token/6]). +-export([add_payment/3]). +-export([save_recurrent_token/4]). -export_type([cascade_tokens/0]). @@ -24,6 +26,11 @@ 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 @@ -39,21 +46,22 @@ get_recurrent_tokens(InvoiceID, PaymentID) -> 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(), - invoice_id(), - payment_id() + token() ) -> ok. save_recurrent_token( CustomerID, BankCardToken, #domain_PaymentRoute{provider = ProviderRef, terminal = TerminalRef}, - RecToken, - InvoiceID, - PaymentID + RecToken ) -> {ok, #customer_BankCard{id = BankCardID}} = call( customer_management, @@ -70,11 +78,6 @@ save_recurrent_token( token = RecToken }} ), - {ok, ok} = call( - customer_management, - 'AddPayment', - {CustomerID, InvoiceID, PaymentID} - ), ok. %% diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index b2412276..176ad95a 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -2470,22 +2470,21 @@ maybe_save_recurrent_token_to_customer( #st{ payment = #domain_InvoicePayment{ id = PaymentID, - make_recurrent = true, customer_id = CustomerID, + make_recurrent = MakeRecurrent, payer = Payer }, recurrent_token = RecToken } = St -) when CustomerID =/= undefined, RecToken =/= undefined -> - case get_bank_card_token(Payer) of - undefined -> - ok; - BankCardToken -> +) 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), - InvoiceID = get_invoice_id(get_invoice(get_opts(St))), - hg_customer_client:save_recurrent_token( - CustomerID, BankCardToken, Route, RecToken, InvoiceID, PaymentID - ) + hg_customer_client:save_recurrent_token(CustomerID, BCT, Route, RT); + _ -> + ok end; maybe_save_recurrent_token_to_customer(_St) -> ok. diff --git a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index ef9c96aa..51852e2b 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -32,6 +32,7 @@ -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]). @@ -93,6 +94,7 @@ groups() -> 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 ]} @@ -475,6 +477,23 @@ different_customer_id_test(C) -> {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), From 4b3bc8af2e58c2e04d25c913d3ab411cc266f9d2 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 20 Mar 2026 20:24:28 +0300 Subject: [PATCH 5/9] Fix --- apps/hellgate/src/hg_invoice_payment.erl | 18 +++++++----------- .../test/hg_direct_recurrent_tests_SUITE.erl | 9 +++++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 176ad95a..5b150004 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -1955,13 +1955,13 @@ run_routing_decision_pipeline(Ctx0, VS, St) -> ] ). -%% With cascade tokens: skip token filter (parent route forced on first attempt, -%% fallback routes use parent's token on retries), keep all candidates for cascade viability. -%% First attempt: force parent route. Retries: normal priority-based selection. +%% 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}) when Tokens =/= undefined -> - {fun(Ctx) -> Ctx end, fun hg_routing:choose_route_with_ctx/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}. @@ -2947,7 +2947,7 @@ construct_proxy_payment( construct_payment_resource(?payment_resource_payer(Resource, _), _St) -> {disposable_payment_resource, Resource}; construct_payment_resource( - ?recurrent_payer(PaymentTool, ?recurrent_parent(InvoiceID, PaymentID), _), + ?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), @@ -2955,11 +2955,7 @@ construct_payment_resource( provider_ref = ProviderRef, terminal_ref = TerminalRef }, - RecToken = - case maps:find(Key, Tokens) of - {ok, T} -> T; - error -> get_recurrent_token(get_payment_state(InvoiceID, PaymentID)) - end, + RecToken = maps:get(Key, Tokens), {recurrent_payment_resource, #proxy_provider_RecurrentPaymentResource{ payment_tool = PaymentTool, rec_token = RecToken diff --git a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index 51852e2b..3d9a2ce7 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -541,6 +541,15 @@ cascade_recurrent_payment_success_test(C) -> 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), From 7b28a622bba98889ee09354d6bbf20cd8bf57046 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Thu, 26 Mar 2026 08:21:25 +0300 Subject: [PATCH 6/9] Changes --- .env | 2 +- .tool-versions | 2 +- apps/hellgate/src/hg_customer_client.erl | 142 +++++--- apps/hellgate/src/hg_invoice_payment.erl | 50 ++- .../test/hg_direct_recurrent_tests_SUITE.erl | 331 +++++++++++++++++- apps/hellgate/test/hg_dummy_provider.erl | 8 +- compose.yaml | 2 +- 7 files changed, 459 insertions(+), 78 deletions(-) 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/.tool-versions b/.tool-versions index b0035532..01ba9ae3 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ rebar 3.24.0 -erlang 27.1.2 +erlang 27.3.4 diff --git a/apps/hellgate/src/hg_customer_client.erl b/apps/hellgate/src/hg_customer_client.erl index a1f10ee6..cf9b9b29 100644 --- a/apps/hellgate/src/hg_customer_client.erl +++ b/apps/hellgate/src/hg_customer_client.erl @@ -3,12 +3,18 @@ -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([tokens_to_map/1]). -export([add_payment/3]). --export([save_recurrent_token/4]). +-export([link_bank_card/2]). -export_type([cascade_tokens/0]). @@ -19,7 +25,62 @@ -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()} +) -> ok. +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, _} = call( + bank_card_storage, + 'AddRecurrentToken', + {#customer_RecurrentTokenParams{ + bank_card_id = BankCardID, + provider_ref = ProviderRef, + terminal_ref = TerminalRef, + token = RecToken + }} + ), + ok. + +-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) -> @@ -42,45 +103,46 @@ get_recurrent_tokens(InvoiceID, PaymentID) -> [] 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( +-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, _} = call( - bank_card_storage, - 'AddRecurrentToken', - {#customer_RecurrentTokenParams{ - bank_card_id = BankCardID, - provider_ref = ProviderRef, - terminal_ref = TerminalRef, - token = RecToken - }} - ), 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), @@ -102,21 +164,3 @@ call(ServiceName, Function, Args) -> }, 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 5b150004..25c7c6b0 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -445,13 +445,16 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> customer_id = InheritedCustomerID }, 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)]; - [] -> [] + 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)]; + [] -> [] + end end; _ -> [] @@ -2479,16 +2482,37 @@ maybe_save_recurrent_token_to_customer( ) 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 -> + maybe_save_recurrent_token_to_bankcard(MakeRecurrent, RecToken, Payer, St), + maybe_link_bankcard_to_customer(CustomerID, Payer); +maybe_save_recurrent_token_to_customer( + #st{ + payment = #domain_InvoicePayment{ + make_recurrent = MakeRecurrent, + payer = Payer + }, + recurrent_token = RecToken + } = St +) -> + maybe_save_recurrent_token_to_bankcard(MakeRecurrent, RecToken, Payer, St). + +maybe_save_recurrent_token_to_bankcard(true, 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(CustomerID, BCT, Route, RT); - _ -> - ok + hg_customer_client:save_recurrent_token_by_card(PartyConfigRef, BCT, {Route, RecToken}) end; -maybe_save_recurrent_token_to_customer(_St) -> +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{ diff --git a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index 3d9a2ce7..55c93f0c 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -36,6 +36,11 @@ -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 @@ -96,7 +101,12 @@ groups() -> different_customer_id_test, regular_payment_saves_to_cubasty_test, cascade_tokens_filter_success_test, - cascade_recurrent_payment_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 ]} ]. @@ -181,7 +191,16 @@ 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())), + 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. @@ -227,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{ @@ -542,13 +561,13 @@ cascade_recurrent_payment_success_test(C) -> %% 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">> + #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), @@ -564,6 +583,162 @@ cascade_recurrent_payment_success_test(C) -> #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). @@ -571,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) -> @@ -611,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. @@ -992,3 +1173,129 @@ construct_cascade_fixture() -> } }} ]. + +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..8dff6af0 100644 --- a/apps/hellgate/test/hg_dummy_provider.erl +++ b/apps/hellgate/test/hg_dummy_provider.erl @@ -610,7 +610,13 @@ 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">>) + case binary:split(Token, <<"/">>) of + [Base, _Suffix] -> + get_payment_tool_scenario({'bank_card', BCard#domain_BankCard{token = Base}}) + end. -type tokenized_bank_card_payment_system() :: { diff --git a/compose.yaml b/compose.yaml index fe68cf6b..c2f82e64 100644 --- a/compose.yaml +++ b/compose.yaml @@ -127,7 +127,7 @@ services: retries: 10 cubasty: - image: ghcr.io/valitydev/cubasty:sha-a1a945f-epic-fix_matched + image: ghcr.io/valitydev/cubasty:sha-f94b6a0-epic-fix_matched volumes: - ./test/cubasty/sys.config:/opt/cs/releases/0.1/sys.config hostname: cubasty From bbebb93d0c0fb90feb141bb574d3645b9ea52a5f Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Thu, 26 Mar 2026 18:34:16 +0300 Subject: [PATCH 7/9] Review fixes --- apps/hellgate/src/hg_customer_client.erl | 6 ++--- apps/hellgate/src/hg_invoice_payment.erl | 28 +++++++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/hellgate/src/hg_customer_client.erl b/apps/hellgate/src/hg_customer_client.erl index cf9b9b29..30828e18 100644 --- a/apps/hellgate/src/hg_customer_client.erl +++ b/apps/hellgate/src/hg_customer_client.erl @@ -57,14 +57,14 @@ get_recurrent_tokens_by_card(PartyConfigRef, BankCardToken) -> dmsl_domain_thrift:'PartyConfigRef'(), token(), {dmsl_domain_thrift:'PaymentRoute'(), token()} -) -> ok. +) -> 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, _} = call( + {ok, SavedToken} = call( bank_card_storage, 'AddRecurrentToken', {#customer_RecurrentTokenParams{ @@ -74,7 +74,7 @@ save_recurrent_token_by_card( token = RecToken }} ), - ok. + SavedToken. -spec tokens_to_map([recurrent_token()]) -> cascade_tokens(). tokens_to_map(Tokens) -> diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 25c7c6b0..e9ce969a 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -452,8 +452,10 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> []; BCT -> case hg_customer_client:get_recurrent_tokens_by_card(PartyConfigRef, BCT) of - [_ | _] = Tokens -> [?cascade_tokens_loaded(Tokens)]; - [] -> [] + [_ | _] = Tokens -> + [?cascade_tokens_loaded(Tokens)]; + [] -> + seed_bank_card_from_parent(PartyConfigRef, BCT, VS0) end end; _ -> @@ -462,6 +464,18 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> 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}) -> @@ -2474,7 +2488,6 @@ maybe_save_recurrent_token_to_customer( payment = #domain_InvoicePayment{ id = PaymentID, customer_id = CustomerID, - make_recurrent = MakeRecurrent, payer = Payer }, recurrent_token = RecToken @@ -2482,20 +2495,19 @@ maybe_save_recurrent_token_to_customer( ) 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(MakeRecurrent, RecToken, Payer, St), + 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{ - make_recurrent = MakeRecurrent, payer = Payer }, recurrent_token = RecToken } = St ) -> - maybe_save_recurrent_token_to_bankcard(MakeRecurrent, RecToken, Payer, St). + maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St). -maybe_save_recurrent_token_to_bankcard(true, RecToken, Payer, St) when RecToken =/= undefined -> +maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St) when RecToken =/= undefined -> case get_bank_card_token(Payer) of undefined -> ok; @@ -2504,7 +2516,7 @@ maybe_save_recurrent_token_to_bankcard(true, RecToken, Payer, St) when RecToken Route = get_route(St), hg_customer_client:save_recurrent_token_by_card(PartyConfigRef, BCT, {Route, RecToken}) end; -maybe_save_recurrent_token_to_bankcard(_, _, _, _) -> +maybe_save_recurrent_token_to_bankcard(_, _, _) -> ok. maybe_link_bankcard_to_customer(CustomerID, Payer) -> From 7837aa1893852f308e51aa7f82f72ce38979e2a3 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Thu, 26 Mar 2026 19:10:54 +0300 Subject: [PATCH 8/9] Fix checks --- apps/hellgate/src/hg_invoice_payment.erl | 4 ++-- apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl | 8 ++++---- apps/hellgate/test/hg_dummy_provider.erl | 6 ++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index e9ce969a..fe77d140 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -2423,7 +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), + _ = maybe_save_recurrent_token_to_customer(St), NewAction = get_action(Target, Action, St), {done, {[?payment_status_changed(Target)], NewAction}}. @@ -2495,7 +2495,7 @@ maybe_save_recurrent_token_to_customer( ) 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_save_recurrent_token_to_bankcard(RecToken, Payer, St), maybe_link_bankcard_to_customer(CustomerID, Payer); maybe_save_recurrent_token_to_customer( #st{ diff --git a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index 55c93f0c..93471ae0 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -564,7 +564,7 @@ cascade_recurrent_payment_success_test(C) -> #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( + _ = hg_customer_client:save_recurrent_token_by_card( PartyConfigRef, BCT1, {#domain_PaymentRoute{provider = ?prv(2), terminal = ?trm(2)}, <<"cascade-token-prv2">>} @@ -647,7 +647,7 @@ new_client_old_card_cascade_test(C) -> 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( + _ = hg_customer_client:save_recurrent_token_by_card( PartyConfigRef, BCT, {#domain_PaymentRoute{provider = ?prv(2), terminal = ?trm(2)}, <<"cascade-token-prv2">>} @@ -711,12 +711,12 @@ cascade_routing_filter_test(C) -> 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( + _ = 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( + _ = hg_customer_client:save_recurrent_token_by_card( PartyConfigRef, BCT, {#domain_PaymentRoute{provider = ?prv(3), terminal = ?trm(3)}, <<"token-prv3">>} diff --git a/apps/hellgate/test/hg_dummy_provider.erl b/apps/hellgate/test/hg_dummy_provider.erl index 8dff6af0..ea0ab4a3 100644 --- a/apps/hellgate/test/hg_dummy_provider.erl +++ b/apps/hellgate/test/hg_dummy_provider.erl @@ -613,10 +613,8 @@ get_payment_tool_scenario( mobile_commerce; get_payment_tool_scenario({'bank_card', #domain_BankCard{token = Token} = BCard}) -> %% Strip unique test suffix (e.g. <<"no_preauth/42">> -> <<"no_preauth">>) - case binary:split(Token, <<"/">>) of - [Base, _Suffix] -> - get_payment_tool_scenario({'bank_card', BCard#domain_BankCard{token = Base}}) - end. + [Base, _Suffix] = binary:split(Token, <<"/">>), + get_payment_tool_scenario({'bank_card', BCard#domain_BankCard{token = Base}}). -type tokenized_bank_card_payment_system() :: { From eac7a9055d675a2dc561aacd7e98c6290e1e9921 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Mon, 30 Mar 2026 08:30:24 +0300 Subject: [PATCH 9/9] Parent route doesn't matter --- apps/hellgate/src/hg_invoice_payment.erl | 24 +++---------------- .../test/hg_direct_recurrent_tests_SUITE.erl | 6 ++--- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index fe77d140..530d992b 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -1972,22 +1972,9 @@ run_routing_decision_pipeline(Ctx0, VS, St) -> ] ). -%% 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 @@ -2045,13 +2032,7 @@ route_args(St) -> 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]); + gather_routes(PaymentInstitution, VS, Revision, St); build_routing_context(PaymentInstitution, VS, Revision, St) -> Payer = get_payment_payer(St), case get_predefined_route(Payer) of @@ -2505,7 +2486,8 @@ maybe_save_recurrent_token_to_customer( recurrent_token = RecToken } = St ) -> - maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St). + _ = maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St), + ok. maybe_save_recurrent_token_to_bankcard(RecToken, Payer, St) when RecToken =/= undefined -> case get_bank_card_token(Payer) of diff --git a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl index 93471ae0..6c8c627d 100644 --- a/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl +++ b/apps/hellgate/test/hg_direct_recurrent_tests_SUITE.erl @@ -569,7 +569,7 @@ cascade_recurrent_payment_success_test(C) -> 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) + %% Step 4: Routing picks from candidates filtered by tokens; ?prv(1) fails, cascades 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">>)), @@ -654,8 +654,8 @@ new_client_old_card_cascade_test(C) -> ), %% 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) + %% Step 4: "New client" recurrent payment — routing selects from token-filtered candidates + %% prv(1) fails, cascades 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">>)),