From 057c29dc9c63308893fb1adb2b4acac54cf247d3 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 20 Mar 2026 00:59:23 +0300 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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),