From 6046342d26965f3c500de5b0e6f5c5e0b2899da0 Mon Sep 17 00:00:00 2001 From: ttt161 Date: Thu, 17 Sep 2026 08:51:06 +0300 Subject: [PATCH 1/5] XYZ-467/support for provider guarantee accounts for payments --- Makefile | 3 + apps/hellgate/src/hg_accounting.erl | 17 +- .../test/hg_invoice_cashflow_SUITE.erl | 271 ++++++++++++++++++ compose.yaml | 4 +- rebar.config | 6 +- rebar.lock | 4 +- 6 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 apps/hellgate/test/hg_invoice_cashflow_SUITE.erl diff --git a/Makefile b/Makefile index d33a164d..3abef214 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,9 @@ common-test: common-test.%: apps/hellgate/test/hg_%_tests_SUITE.erl $(REBAR) ct --cover --suite=$^ $(if $(CT_CASE),--case=$(strip $(CT_CASE))) +common-test.invoice-cashflow: apps/hellgate/test/hg_invoice_cashflow_SUITE.erl + $(REBAR) ct --cover --suite=$^ $(if $(CT_CASE),--case=$(strip $(CT_CASE))) + cover: $(REBAR) covertool generate diff --git a/apps/hellgate/src/hg_accounting.erl b/apps/hellgate/src/hg_accounting.erl index 94a0f00f..f7ae1565 100644 --- a/apps/hellgate/src/hg_accounting.erl +++ b/apps/hellgate/src/hg_accounting.erl @@ -125,11 +125,18 @@ collect_merchant_account_map(PartyConfigRef, {ShopConfigRef, #domain_ShopConfig{ -spec collect_provider_account_map(payment(), provider(), route(), map()) -> map(). collect_provider_account_map(Payment, #domain_Provider{accounts = ProviderAccounts}, Route, Acc) -> Currency = get_currency(get_payment_cost(Payment)), - ProviderAccount = hg_payment_institution:choose_provider_account(Currency, ProviderAccounts), - Acc#{ - provider => Route, - {provider, settlement} => ProviderAccount#domain_ProviderAccount.settlement - }. + #domain_ProviderAccount{ + settlement = Settlement, + guarantee = Guarantee + } = hg_payment_institution:choose_provider_account(Currency, ProviderAccounts), + maps:merge( + Acc, + genlib_map:compact(#{ + provider => Route, + {provider, settlement} => Settlement, + {provider, guarantee} => Guarantee + }) + ). -spec collect_system_account_map(payment(), payment_institution(), revision(), map()) -> map(). collect_system_account_map(Payment, PaymentInstitution, Revision, Acc) -> diff --git a/apps/hellgate/test/hg_invoice_cashflow_SUITE.erl b/apps/hellgate/test/hg_invoice_cashflow_SUITE.erl new file mode 100644 index 00000000..662b88e2 --- /dev/null +++ b/apps/hellgate/test/hg_invoice_cashflow_SUITE.erl @@ -0,0 +1,271 @@ +-module(hg_invoice_cashflow_SUITE). + +-include_lib("hellgate/include/hg_invoice.hrl"). +-include_lib("hellgate/include/payment_events.hrl"). +-include_lib("hellgate/include/invoice_events.hrl"). +-include_lib("stdlib/include/assert.hrl"). +-include("hg_ct_domain.hrl"). +-include("hg_ct_invoice.hrl"). + +-export([all/0]). +-export([groups/0]). +-export([init_per_suite/1]). +-export([end_per_suite/1]). +-export([init_per_group/2]). +-export([end_per_group/2]). +-export([init_per_testcase/2]). +-export([end_per_testcase/2]). + +%% Tests +-export([payment_with_provider_settlement_account/1]). +-export([payment_with_provider_guarantee_account/1]). +-export([payment_undefined_provider_guarantee_accout/1]). + +-type config() :: hg_ct_helper:config(). +-type test_case_name() :: hg_ct_helper:test_case_name(). +-type group_name() :: hg_ct_helper:group_name(). +-type test_return() :: _ | no_return(). + +%% Supervisor +-behaviour(supervisor). + +-export([init/1]). + +-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. +init([]) -> + {ok, {#{strategy => one_for_all, intensity => 1, period => 1}, []}}. + +-spec all() -> [test_case_name() | {group, group_name()}]. +all() -> + [ + payment_with_provider_settlement_account, + payment_with_provider_guarantee_account, + payment_undefined_provider_guarantee_accout + ]. + +-spec groups() -> [{group_name(), list(), [test_case_name()]}]. +groups() -> + []. + +-spec init_per_suite(config()) -> config(). +init_per_suite(C) -> + CowboySpec = hg_dummy_provider:get_http_cowboy_spec(), + {Apps, Ret} = hg_ct_helper:start_apps([ + woody, + scoper, + dmt_client, + bender_client, + party_client, + hg_proto, + epg_connector, + progressor, + hellgate, + {cowboy, CowboySpec}, + snowflake + ]), + RootUrl = maps:get(hellgate_root_url, Ret), + _ = hg_limiter_helper:init_per_suite(C), + _ = hg_domain:upsert(hg_invoice_dummy_data:construct_domain_fixture()), + PartyConfigRef = #domain_PartyConfigRef{id = hg_utils:unique_id()}, + PartyClient = {party_client:create_client(), party_client:create_context()}, + ok = op_context:save(op_context:key(hellgate), op_context:create()), + ShopConfigRef = hg_ct_helper:create_party_and_shop( + PartyConfigRef, ?cat(1), <<"RUB">>, ?trms(1), ?pinst(1), PartyClient + ), + ok = op_context:cleanup(hellgate), + {ok, SupPid} = supervisor:start_link(?MODULE, []), + _ = unlink(SupPid), + ok = hg_invoice_helper:start_kv_store(SupPid), + C1 = [ + {party_config_ref, PartyConfigRef}, + {shop_config_ref, ShopConfigRef}, + {root_url, RootUrl}, + {test_sup, SupPid}, + {apps, Apps} + | C + ], + ok = hg_invoice_helper:start_proxies([{hg_dummy_provider, 1, C1}, {hg_dummy_inspector, 2, C1}]), + [{base_domain_revision, hg_domain:head()} | C1]. + +-spec end_per_suite(config()) -> _. +end_per_suite(C) -> + _ = hg_domain:cleanup(), + _ = application:stop(progressor), + _ = hg_ct_helper:cleanup_progressor_namespaces(), + _ = [application:stop(App) || App <- cfg(apps, C)], + hg_invoice_helper:stop_kv_store(cfg(test_sup, C)), + exit(cfg(test_sup, C), shutdown). + +-spec init_per_group(group_name(), config()) -> config(). +init_per_group(_, C) -> + C. + +-spec end_per_group(group_name(), config()) -> _. +end_per_group(_Group, _C) -> + ok. + +-spec init_per_testcase(test_case_name(), config()) -> config(). +init_per_testcase(Name, C) -> + _ = hg_domain:reset(cfg(base_domain_revision, C)), + ApiClient = hg_ct_helper:create_client(cfg(root_url, C)), + Client = hg_client_invoicing:start_link(ApiClient), + ok = op_context:save(op_context:key(hellgate), op_context:create()), + C1 = [{client, Client} | C], + case Name of + payment_with_provider_guarantee_account -> + GuaranteeAccountID = configure_provider_guarantee_account(), + [{provider_guarantee_account_id, GuaranteeAccountID} | C1]; + payment_undefined_provider_guarantee_accout -> + ok = configure_provider_guarantee_cashflow(), + C1; + _ -> + C1 + end. + +-spec end_per_testcase(test_case_name(), config()) -> config(). +end_per_testcase(_, C) -> + ok = op_context:cleanup(hellgate), + C. + +%% Tests + +-spec payment_with_provider_settlement_account(config()) -> test_return(). +payment_with_provider_settlement_account(C) -> + Amount = 42000, + {CashFlow, Route} = execute_payment(Amount, C), + #domain_Provider{accounts = ProviderAccounts} = hg_domain:get({provider, ?prv(1)}), + #domain_ProviderAccount{settlement = SettlementAccountID, guarantee = undefined} = + maps:get(?cur(<<"RUB">>), ProviderAccounts), + [ + #domain_FinalCashFlowPosting{ + source = #domain_FinalCashFlowAccount{ + account_id = SettlementAccountID, + transaction_account = + {provider, #domain_ProviderTransactionAccount{ + type = settlement, + owner = #domain_ProviderTransactionAccountOwner{ + provider_ref = ?prv(1), + terminal_ref = ?trm(1) + } + }} + }, + volume = ?cash(Amount, <<"RUB">>) + } + ] = lookup_posting(CashFlow, {provider, settlement}, {merchant, settlement}), + assert_route(Route), + ok. + +-spec payment_with_provider_guarantee_account(config()) -> test_return(). +payment_with_provider_guarantee_account(C) -> + Amount = 42000, + GuaranteeAccountID = cfg(provider_guarantee_account_id, C), + {CashFlow, Route} = execute_payment(Amount, C), + [ + #domain_FinalCashFlowPosting{ + source = #domain_FinalCashFlowAccount{ + account_id = GuaranteeAccountID, + transaction_account = + {provider, #domain_ProviderTransactionAccount{ + type = guarantee, + owner = #domain_ProviderTransactionAccountOwner{ + provider_ref = ?prv(1), + terminal_ref = ?trm(1) + } + }} + }, + volume = ?cash(Amount, <<"RUB">>) + } + ] = lookup_posting(CashFlow, {provider, guarantee}, {merchant, settlement}), + #{own_amount := GuaranteeBalance} = hg_accounting:get_balance(GuaranteeAccountID), + ?assertEqual(-Amount, GuaranteeBalance), + assert_route(Route), + ok. + +-spec payment_undefined_provider_guarantee_accout(config()) -> test_return(). +payment_undefined_provider_guarantee_accout(C) -> + Client = cfg(client, C), + #domain_Provider{accounts = ProviderAccounts} = hg_domain:get({provider, ?prv(1)}), + #domain_ProviderAccount{guarantee = undefined} = maps:get(?cur(<<"RUB">>), ProviderAccounts), + + InvoiceID = hg_invoice_helper:start_invoice( + <<"undefined provider guarantee account">>, hg_invoice_helper:make_due_date(10), 42000, C + ), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + ?payment_state(?payment(PaymentID)) = hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client), + Route = hg_invoice_helper:start_payment_ev(InvoiceID, Client), + assert_route(Route), + + %% The configured cash flow cannot be finalized without the provider guarantee account. + %% Cash-flow building fails with a misconfiguration error and emits no further payment event. + timeout = hg_invoice_helper:next_change(InvoiceID, 2000, Client), + #payproc_InvoicePayment{ + payment = #domain_InvoicePayment{status = ?pending()}, + route = Route, + cash_flow = undefined + } = hg_client_invoicing:get_payment(InvoiceID, PaymentID, Client), + ok. + +%% Internals + +execute_payment(Amount, C) -> + Client = cfg(client, C), + InvoiceID = hg_invoice_helper:start_invoice( + <<"provider cashflow">>, hg_invoice_helper:make_due_date(10), Amount, C + ), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + PaymentID = hg_invoice_helper:execute_payment(InvoiceID, PaymentParams, Client), + #payproc_InvoicePayment{route = Route, cash_flow = CashFlow} = + hg_client_invoicing:get_payment(InvoiceID, PaymentID, Client), + {CashFlow, Route}. + +configure_provider_guarantee_account() -> + Currency = ?cur(<<"RUB">>), + GuaranteeAccountID = hg_accounting:create_account(<<"RUB">>), + Provider0 = #domain_Provider{accounts = Accounts0} = hg_domain:get({provider, ?prv(1)}), + ProviderAccount0 = maps:get(Currency, Accounts0), + ProviderAccount1 = ProviderAccount0#domain_ProviderAccount{guarantee = GuaranteeAccountID}, + Provider1 = Provider0#domain_Provider{accounts = Accounts0#{Currency => ProviderAccount1}}, + _ = hg_domain:upsert({provider, #domain_ProviderObject{ref = ?prv(1), data = Provider1}}), + ok = configure_provider_guarantee_cashflow(), + GuaranteeAccountID. + +configure_provider_guarantee_cashflow() -> + Terminal0 = #domain_Terminal{terms = Terms0} = hg_domain:get({terminal, ?trm(1)}), + PaymentTerms0 = Terms0#domain_ProvisionTermSet.payments, + CashFlow = [ + ?cfpost( + {provider, guarantee}, + {merchant, settlement}, + ?share(1, 1, operation_amount) + ), + ?cfpost( + {system, settlement}, + {provider, settlement}, + ?fixed(10, <<"RUB">>) + ) + ], + PaymentTerms1 = PaymentTerms0#domain_PaymentsProvisionTerms{cash_flow = {value, CashFlow}}, + Terminal1 = Terminal0#domain_Terminal{ + terms = Terms0#domain_ProvisionTermSet{payments = PaymentTerms1} + }, + _ = hg_domain:upsert({terminal, #domain_TerminalObject{ref = ?trm(1), data = Terminal1}}), + ok. + +lookup_posting(CashFlow, Source, Destination) -> + lists:filter( + fun( + #domain_FinalCashFlowPosting{ + source = #domain_FinalCashFlowAccount{account_type = SourceAccount}, + destination = #domain_FinalCashFlowAccount{account_type = DestinationAccount} + } + ) -> + Source =:= SourceAccount andalso Destination =:= DestinationAccount + end, + CashFlow + ). + +assert_route(#domain_PaymentRoute{provider = ?prv(1), terminal = ?trm(1)}) -> + ok. + +cfg(Key, Config) -> + hg_ct_helper:cfg(Key, Config). diff --git a/compose.yaml b/compose.yaml index 6aeaf1fc..996c6851 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,7 +33,7 @@ services: command: /sbin/init dmt: - image: ghcr.io/valitydev/dominant-v2:sha-44843aa + image: ghcr.io/valitydev/dominant-v2:sha-6a0a26c-epic-XYZ-467-provider-guarantee-account command: /opt/dmt/bin/dmt foreground environment: DMT_KAFKA_ENABLED: "0" @@ -115,7 +115,7 @@ services: retries: 20 party-management: - image: ghcr.io/valitydev/party-management:sha-89e6247 + image: ghcr.io/valitydev/party-management:sha-89e6278-epic-XYZ-467-provider-guaratntee-account command: /opt/party-management/bin/party-management foreground volumes: - ./test/party-management/sys.config:/opt/party-management/releases/0.1/sys.config diff --git a/rebar.config b/rebar.config index 89c948c4..b21fdf2b 100644 --- a/rebar.config +++ b/rebar.config @@ -36,7 +36,8 @@ {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.2"}}}, {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {thrift, {git, "https://github.com/valitydev/thrift_erlang.git", {tag, "v1.0.0"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.46"}}}, + %{damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.46"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "ft/XYZ-467/provider-guarantee-account"}}}, {exrates_proto, {git, "https://github.com/valitydev/exrates-proto.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"}}}, @@ -48,7 +49,8 @@ {herd, {git, "https://github.com/wgnet/herd.git", {tag, "1.3.4"}}}, {progressor, {git, "https://github.com/valitydev/progressor.git", {tag, "v1.0.28"}}}, {machinery, {git, "https://github.com/valitydev/machinery-erlang.git", {tag, "v1.1.22"}}}, - {fistful_proto, {git, "https://github.com/valitydev/fistful-proto.git", {branch, "master"}}}, + %{fistful_proto, {git, "https://github.com/valitydev/fistful-proto.git", {branch, "master"}}}, + {fistful_proto, {git, "https://github.com/valitydev/fistful-proto.git", {branch, "ft/XYZ-467/provider-guarantee-cashflow"}}}, {binbase_proto, {git, "https://github.com/valitydev/binbase-proto.git", {branch, "master"}}}, {validator_personal_data_proto, {git, "https://github.com/valitydev/validator-personal-data-proto.git", {branch, "master"}}}, diff --git a/rebar.lock b/rebar.lock index 564478d6..7165f78a 100644 --- a/rebar.lock +++ b/rebar.lock @@ -31,7 +31,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"77d93d016468742051687953ddf65aa02d3bf91e"}}, + {ref,"5fa86e3032142ebe431de109e4c00eb829489a2e"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git", @@ -69,7 +69,7 @@ 0}, {<<"fistful_proto">>, {git,"https://github.com/valitydev/fistful-proto.git", - {ref,"94b75cef84a6e798887a7e20a66674ce24f3111e"}}, + {ref,"ae79e12ab44e7e354803fefc97857949258ab1d0"}}, 0}, {<<"genlib">>, {git,"https://github.com/valitydev/genlib.git", From 34785b33aea50974079b4e7d264abecc05f95dc8 Mon Sep 17 00:00:00 2001 From: ttt161 Date: Thu, 17 Sep 2026 12:48:59 +0300 Subject: [PATCH 2/5] XYZ-467/support for provider guarantee accounts for payments --- Makefile | 10 +- apps/ff_cth/src/ct_domain.erl | 61 ++++++++- apps/ff_server/src/ff_cash_flow_codec.erl | 24 +++- .../test/ff_withdrawal_handler_SUITE.erl | 109 ++++++++++++++- apps/ff_transfer/src/ff_withdrawal.erl | 5 +- apps/ff_transfer/test/ff_withdrawal_SUITE.erl | 124 +++++++++++++++++- .../test/ff_withdrawal_adjustment_SUITE.erl | 75 ++++++++++- apps/fistful/src/ff_cash_flow.erl | 3 +- apps/fistful/src/ff_payouts_provider.erl | 29 +++- rebar.config | 3 +- 10 files changed, 426 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 3abef214..e1748cc2 100644 --- a/Makefile +++ b/Makefile @@ -105,10 +105,16 @@ eunit: common-test: $(REBAR) ct --cover -common-test.%: apps/hellgate/test/hg_%_tests_SUITE.erl +common-hg-test.%: apps/hellgate/test/hg_%_SUITE.erl $(REBAR) ct --cover --suite=$^ $(if $(CT_CASE),--case=$(strip $(CT_CASE))) -common-test.invoice-cashflow: apps/hellgate/test/hg_invoice_cashflow_SUITE.erl +common-ff-server-test.%: apps/ff_server/test/ff_%_SUITE.erl + $(REBAR) ct --cover --suite=$^ $(if $(CT_CASE),--case=$(strip $(CT_CASE))) + +common-ff-transfer-test.%: apps/ff_transfer/test/ff_%_SUITE.erl + $(REBAR) ct --cover --suite=$^ $(if $(CT_CASE),--case=$(strip $(CT_CASE))) + +common-fistful-test.%: apps/fistful/test/ff_%_SUITE.erl $(REBAR) ct --cover --suite=$^ $(if $(CT_CASE),--case=$(strip $(CT_CASE))) cover: diff --git a/apps/ff_cth/src/ct_domain.erl b/apps/ff_cth/src/ct_domain.erl index 91f59716..b5fa64f7 100644 --- a/apps/ff_cth/src/ct_domain.erl +++ b/apps/ff_cth/src/ct_domain.erl @@ -27,6 +27,9 @@ -export([globals/2]). -export([withdrawal_provider/4]). -export([withdrawal_provider/5]). +-export([withdrawal_provider/6]). +-export([withdrawal_terms/2]). +-export([withdrawal_provider_with_guarantee/3]). -export([withdrawal_terminal/2]). -export([withdrawal_terminal/3]). @@ -130,7 +133,18 @@ withdrawal_provider(Ref, ProxyRef, Realm, TermSet) -> ?DTP('PaymentInstitutionRealm'), ?DTP('ProvisionTermSet') | undefined ) -> object(). -withdrawal_provider(AccountID, ?prv(ID) = Ref, ProxyRef, Realm, TermSet) -> +withdrawal_provider(AccountID, Ref, ProxyRef, Realm, TermSet) -> + withdrawal_provider(AccountID, undefined, Ref, ProxyRef, Realm, TermSet). + +-spec withdrawal_provider( + ff_account:account_id(), + ff_account:account_id() | undefined, + ?DTP('ProviderRef'), + ?DTP('ProxyRef'), + ?DTP('PaymentInstitutionRealm'), + ?DTP('ProvisionTermSet') | undefined +) -> object(). +withdrawal_provider(SettlementAccountID, GuaranteeAccountID, ?prv(ID) = Ref, ProxyRef, Realm, TermSet) -> {provider, #domain_ProviderObject{ ref = Ref, data = #domain_Provider{ @@ -140,9 +154,52 @@ withdrawal_provider(AccountID, ?prv(ID) = Ref, ProxyRef, Realm, TermSet) -> realm = Realm, terms = TermSet, accounts = #{ - ?cur(<<"RUB">>) => #domain_ProviderAccount{settlement = AccountID} + ?cur(<<"RUB">>) => #domain_ProviderAccount{ + settlement = SettlementAccountID, + guarantee = GuaranteeAccountID + } + } + } + }}. + +-spec withdrawal_terms(currency(), [dmsl_domain_thrift:'CashFlowPosting'()]) -> + ?DTP('ProvisionTermSet'). +withdrawal_terms(Currency, CashFlow) -> + #domain_ProvisionTermSet{ + wallet = #domain_WalletProvisionTerms{ + withdrawals = #domain_WithdrawalProvisionTerms{ + currencies = {value, ?ordset([?cur(Currency)])}, + cash_limit = + {value, + ?cashrng( + {inclusive, ?cash(0, Currency)}, + {exclusive, ?cash(10000000, Currency)} + )}, + cash_flow = {value, CashFlow} } } + }. + +-spec withdrawal_provider_with_guarantee( + ?DTP('ProviderRef'), + ff_account:account_id(), + ?DTP('ProvisionTermSet') | undefined +) -> object(). +withdrawal_provider_with_guarantee(?prv(ID) = Ref, GuaranteeAccountID, TermSet) -> + #domain_Provider{accounts = Accounts} = Provider = ct_domain_config:get({provider, Ref}), + ProviderAccount = maps:get(?cur(<<"RUB">>), Accounts), + {provider, #domain_ProviderObject{ + ref = Ref, + data = Provider#domain_Provider{ + name = genlib:format("Withdrawal provider #~B", [ID]), + terms = TermSet, + accounts = + Accounts#{ + ?cur(<<"RUB">>) => ProviderAccount#domain_ProviderAccount{ + guarantee = GuaranteeAccountID + } + } + } }}. -spec withdrawal_terminal(?DTP('TerminalRef'), ?DTP('ProviderRef')) -> object(). diff --git a/apps/ff_server/src/ff_cash_flow_codec.erl b/apps/ff_server/src/ff_cash_flow_codec.erl index 0c580004..8487262d 100644 --- a/apps/ff_server/src/ff_cash_flow_codec.erl +++ b/apps/ff_server/src/ff_cash_flow_codec.erl @@ -118,10 +118,32 @@ final_cash_flow_symmetry_test() -> volume => {100, <<"EUR">>} } end, + ProviderGuaranteePosting = #{ + sender => #{ + account => #{ + realm => test, + party_id => genlib:unique(), + currency => <<"RUB">>, + account_id => 456 + }, + type => {provider, settlement} + }, + receiver => #{ + account => #{ + realm => test, + party_id => genlib:unique(), + currency => <<"RUB">>, + account_id => 789 + }, + type => {provider, guarantee} + }, + volume => {100, <<"RUB">>} + }, CashFlow = #{ postings => [ PostingFn(), - PostingFn() + PostingFn(), + ProviderGuaranteePosting ] }, ?assertEqual(CashFlow, unmarshal(final_cash_flow, marshal(final_cash_flow, CashFlow))). diff --git a/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl b/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl index f7b65406..070cf3e8 100644 --- a/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl +++ b/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl @@ -9,6 +9,7 @@ -include_lib("fistful_proto/include/fistful_fistful_thrift.hrl"). -include_lib("fistful_proto/include/fistful_fistful_base_thrift.hrl"). -include_lib("fistful_proto/include/fistful_cashflow_thrift.hrl"). +-include_lib("fistful_proto/include/fistful_account_thrift.hrl"). -include_lib("fistful_proto/include/fistful_transfer_thrift.hrl"). -include_lib("ff_cth/include/ct_domain.hrl"). @@ -48,6 +49,7 @@ -export([create_adjustment_invalid_operation_amount_error_test/1]). -export([create_adjustment_change_body_ok_test/1]). -export([withdrawal_state_content_test/1]). +-export([withdrawal_with_provider_guarantee_account_test/1]). -export([trace_withdrawal_test/1]). -export([create_withdrawal_with_changed_body_test/1]). @@ -98,7 +100,8 @@ groups() -> create_adjustment_already_has_body_error_test, create_adjustment_invalid_operation_amount_error_test, create_adjustment_change_body_ok_test, - withdrawal_state_content_test + withdrawal_state_content_test, + withdrawal_with_provider_guarantee_account_test ]} ]. @@ -129,12 +132,31 @@ end_per_group(_, _) -> %% -spec init_per_testcase(test_case_name(), config()) -> config(). +init_per_testcase(withdrawal_with_provider_guarantee_account_test = Name, C) -> + C1 = ct_helper:makeup_cfg([ct_helper:test_case_name(Name), ct_helper:woody_ctx()], C), + ok = ct_helper:set_context(C1), + WasRevision = ct_domain_config:head(), + GuaranteeAccountID = configure_provider_guarantee_account(), + [ + {domain_revision, WasRevision}, + {provider_guarantee_account_id, GuaranteeAccountID} + | C1 + ]; init_per_testcase(Name, C) -> C1 = ct_helper:makeup_cfg([ct_helper:test_case_name(Name), ct_helper:woody_ctx()], C), ok = ct_helper:set_context(C1), C1. -spec end_per_testcase(test_case_name(), config()) -> _. +end_per_testcase(withdrawal_with_provider_guarantee_account_test, C) -> + _ = + case lists:keyfind(domain_revision, 1, C) of + {domain_revision, WasRevision} -> + _ = ct_domain_config:reset(WasRevision); + false -> + ok + end, + ok = ct_helper:unset_context(); end_per_testcase(_Name, _C) -> ok = ct_helper:unset_context(). @@ -936,6 +958,67 @@ withdrawal_state_content_test(_C) -> ?assertNotEqual(undefined, WithdrawalState#wthd_WithdrawalState.effective_route), ?assertNotEqual(undefined, WithdrawalState#wthd_WithdrawalState.status). +-spec withdrawal_with_provider_guarantee_account_test(config()) -> test_return(). +withdrawal_with_provider_guarantee_account_test(C) -> + Cash = make_cash({100, <<"RUB">>}), + GuaranteeAccountID = ct_helper:cfg(provider_guarantee_account_id, C), + Ctx = ct_objects:build_default_ctx(), + #{withdrawal_id := WithdrawalID} = ct_objects:prepare_standard_environment(Ctx#{body => Cash}), + succeeded = ct_objects:await_final_withdrawal_status(WithdrawalID), + + %% the provider fee is the lesser of 10 RUB and 5% of 100 RUB, posted to the guarantee account + {ok, #wthd_WithdrawalState{ + effective_final_cash_flow = #cashflow_FinalCashFlow{postings = Postings} + }} = call_withdrawal('Get', {WithdrawalID, #'fistful_base_EventRange'{}}), + [ + #cashflow_FinalCashFlowPosting{ + source = #cashflow_FinalCashFlowAccount{account_type = {system, settlement}}, + destination = #cashflow_FinalCashFlowAccount{ + account_type = {provider, guarantee}, + account = #'account_Account'{account_id = GuaranteeAccountID} + }, + volume = #fistful_base_Cash{amount = 5, currency = #'fistful_base_CurrencyRef'{symbolic_code = <<"RUB">>}} + } + ] = [ + P + || #cashflow_FinalCashFlowPosting{ + destination = #cashflow_FinalCashFlowAccount{account_type = {provider, guarantee}} + } = P <- + Postings + ], + + %% the same posting must survive in the serialized transfer event + Range = {undefined, undefined}, + EncodedRange = ff_codec:marshal(event_range, Range), + {ok, Events} = call_withdrawal('GetEvents', {WithdrawalID, EncodedRange}), + [CreatedEvent] = [ + E + || #wthd_Event{change = {transfer, #wthd_TransferChange{payload = {created, _}}}} = E <- Events + ], + #wthd_Event{ + change = + {transfer, #wthd_TransferChange{ + payload = + {created, #transfer_CreatedChange{ + transfer = #transfer_Transfer{ + cashflow = #cashflow_FinalCashFlow{postings = EventPostings} + } + }} + }} + } = CreatedEvent, + [ + #cashflow_FinalCashFlowPosting{ + destination = #cashflow_FinalCashFlowAccount{account_type = {provider, guarantee}} + } + ] = [ + P + || #cashflow_FinalCashFlowPosting{ + destination = #cashflow_FinalCashFlowAccount{account_type = {provider, guarantee}} + } = P <- + EventPostings + ], + ok. + %% Internals call_withdrawal_session(Fun, Args) -> @@ -969,3 +1052,27 @@ make_cash({Amount, Currency}) -> amount = Amount, currency = #'fistful_base_CurrencyRef'{symbolic_code = Currency} }. + +configure_provider_guarantee_account() -> + ProviderID = 1, + {ok, GuaranteeAccountID} = ct_helper:create_account(<<"RUB">>), + CashFlow = [ + ?cfpost( + {system, settlement}, + {provider, guarantee}, + {product, + {min_of, + ?ordset([ + ?fixed(10, <<"RUB">>), + ?share(5, 100, operation_amount, round_half_towards_zero) + ])}} + ) + ], + _ = ct_domain_config:upsert( + ct_domain:withdrawal_provider_with_guarantee( + ?prv(ProviderID), + GuaranteeAccountID, + ct_domain:withdrawal_terms(<<"RUB">>, CashFlow) + ) + ), + GuaranteeAccountID. diff --git a/apps/ff_transfer/src/ff_withdrawal.erl b/apps/ff_transfer/src/ff_withdrawal.erl index 3a530de2..56a4ad15 100644 --- a/apps/ff_transfer/src/ff_withdrawal.erl +++ b/apps/ff_transfer/src/ff_withdrawal.erl @@ -1122,6 +1122,8 @@ make_final_cash_flow(DomainRevision, Withdrawal) -> {ok, Provider} = ff_payouts_provider:get(ProviderID, DomainRevision), ProviderAccounts = ff_payouts_provider:accounts(Provider), ProviderAccount = maps:get(CurrencyID, ProviderAccounts, undefined), + ProviderSettlementAccount = maps:get(settlement, ProviderAccount, undefined), + ProviderGuaranteeAccount = maps:get(guarantee, ProviderAccount, undefined), #domain_WalletConfig{payment_institution = PaymentInstitutionRef} = Wallet, {ok, PaymentInstitution} = ff_payment_institution:get(PaymentInstitutionRef, PartyVarset, DomainRevision), @@ -1144,7 +1146,8 @@ make_final_cash_flow(DomainRevision, Withdrawal) -> {wallet, receiver_destination} => DestinationAccount, {system, settlement} => SettlementAccount, {system, subagent} => SubagentAccount, - {provider, settlement} => ProviderAccount + {provider, settlement} => ProviderSettlementAccount, + {provider, guarantee} => ProviderGuaranteeAccount }), {ok, FinalCashFlow} = ff_cash_flow:finalize(CashFlowPlan, Accounts, Constants), diff --git a/apps/ff_transfer/test/ff_withdrawal_SUITE.erl b/apps/ff_transfer/test/ff_withdrawal_SUITE.erl index ce5ad64d..b6b3adb5 100644 --- a/apps/ff_transfer/test/ff_withdrawal_SUITE.erl +++ b/apps/ff_transfer/test/ff_withdrawal_SUITE.erl @@ -50,6 +50,7 @@ -export([provider_terminal_terms_merging_test/1]). -export([force_status_change_test/1]). -export([withdrawal_without_termset_test/1]). +-export([provider_guarantee_account_test/1]). %% Internal types @@ -122,7 +123,8 @@ groups() -> provider_terminal_terms_merging_test ]}, {non_parallel, [], [ - use_quote_revisions_test + use_quote_revisions_test, + provider_guarantee_account_test ]}, {withdrawal_repair, [], [ force_status_change_test @@ -192,6 +194,23 @@ end_per_group(_, _) -> %% -spec init_per_testcase(test_case_name(), config()) -> config(). +init_per_testcase(provider_guarantee_account_test = Name, C) -> + C1 = ct_helper:makeup_cfg( + [ + ct_helper:test_case_name(Name), + ct_helper:woody_ctx() + ], + C + ), + ok = ct_helper:set_context(C1), + WasRevision = ct_domain_config:head(), + {SettlementAccountID, GuaranteeAccountID} = configure_provider_guarantee_account(), + [ + {domain_revision, WasRevision}, + {provider_settlement_account_id, SettlementAccountID}, + {provider_guarantee_account_id, GuaranteeAccountID} + | C1 + ]; init_per_testcase(Name, C) -> C1 = ct_helper:makeup_cfg( [ @@ -204,6 +223,15 @@ init_per_testcase(Name, C) -> C1. -spec end_per_testcase(test_case_name(), config()) -> _. +end_per_testcase(provider_guarantee_account_test, C) -> + _ = + case lists:keyfind(domain_revision, 1, C) of + {domain_revision, WasRevision} -> + _ = ct_domain_config:reset(WasRevision); + false -> + ok + end, + ok = ct_helper:unset_context(); end_per_testcase(_Name, _C) -> ok = ct_helper:unset_context(). @@ -800,6 +828,56 @@ withdrawal_without_termset_test(C) -> ), ok. +-spec provider_guarantee_account_test(config()) -> test_return(). +provider_guarantee_account_test(C) -> + Cash = {300, <<"RUB">>}, + #{ + wallet_id := WalletID, + destination_id := DestinationID, + party_id := PartyID + } = prepare_standard_environment(Cash, C), + GuaranteeAccountID = ct_helper:cfg(provider_guarantee_account_id, C), + SettlementAccountID = ct_helper:cfg(provider_settlement_account_id, C), + %% the settlement account is shared between the suite cases, so only its change is asserted + SettlementBefore = get_account_amount(SettlementAccountID), + GuaranteeBefore = get_account_amount(GuaranteeAccountID), + WithdrawalID = genlib:bsuuid(), + WithdrawalParams = #{ + id => WithdrawalID, + destination_id => DestinationID, + wallet_id => WalletID, + party_id => PartyID, + body => Cash, + external_id => WithdrawalID + }, + ok = ff_withdrawal_machine:create(WithdrawalParams, ff_entity_context:new()), + ?assertEqual(succeeded, await_final_withdrawal_status(WithdrawalID)), + ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_wallet_balance(WalletID)), + ?assertEqual(?FINAL_BALANCE(240, <<"RUB">>), get_destination_balance(DestinationID)), + + Withdrawal = get_withdrawal(WithdrawalID), + #{ + postings := Postings + } = ff_withdrawal:effective_final_cash_flow(Withdrawal), + %% the provider fee is the lesser of 10 RUB and 5% of 300 RUB, posted to the guarantee account + [ + #{ + sender := #{type := {system, settlement}}, + receiver := #{type := {provider, guarantee}, account := ReceiverAccount}, + volume := Volume + } + ] = [ + P + || #{receiver := #{type := {provider, guarantee}}} = P <- Postings + ], + ?assertEqual(GuaranteeAccountID, maps:get(account_id, ReceiverAccount)), + ?assertEqual({10, <<"RUB">>}, Volume), + + %% the provider fee is credited to the guarantee account only, the settlement one is left intact + ?assertEqual(GuaranteeBefore + 10, get_account_amount(GuaranteeAccountID)), + ?assertEqual(SettlementBefore, get_account_amount(SettlementAccountID)), + ok. + -spec unknown_test(config()) -> test_return(). unknown_test(_C) -> WithdrawalID = <<"unknown_withdrawal">>, @@ -1035,6 +1113,50 @@ await_wallet_balance({Amount, Currency}, ID) -> get_wallet_balance(ID) -> ct_objects:get_wallet_balance(ID). +get_destination_balance(ID) -> + {ok, Machine} = ff_destination_machine:get(ID), + Destination = ff_destination_machine:destination(Machine), + get_account_balance(ff_destination:account(Destination)). + +get_account_balance(AccountID) when is_integer(AccountID) -> + {ok, {Amounts, Currency}} = ff_accounting:balance(AccountID, <<"RUB">>), + {ff_indef:current(Amounts), ff_indef:to_range(Amounts), Currency}; +get_account_balance(Account) -> + {ok, {Amounts, Currency}} = ff_accounting:balance(Account), + {ff_indef:current(Amounts), ff_indef:to_range(Amounts), Currency}. + +get_account_amount(AccountID) -> + {Amount, _Range, _Currency} = get_account_balance(AccountID), + Amount. + +configure_provider_guarantee_account() -> + ProviderID = 17, + {ok, Provider} = ff_payouts_provider:get(ProviderID, ct_domain_config:head()), + ProviderAccounts = ff_payouts_provider:accounts(Provider), + #{settlement := SettlementAccount} = maps:get(<<"RUB">>, ProviderAccounts), + SettlementAccountID = ff_account:account_id(SettlementAccount), + {ok, GuaranteeAccountID} = ct_helper:create_account(<<"RUB">>), + CashFlow = [ + ?cfpost( + {system, settlement}, + {provider, guarantee}, + {product, + {min_of, + ?ordset([ + ?fixed(10, <<"RUB">>), + ?share(5, 100, operation_amount, round_half_towards_zero) + ])}} + ) + ], + _ = ct_domain_config:upsert( + ct_domain:withdrawal_provider_with_guarantee( + ?prv(ProviderID), + GuaranteeAccountID, + ct_domain:withdrawal_terms(<<"RUB">>, CashFlow) + ) + ), + {SettlementAccountID, GuaranteeAccountID}. + create_crypto_destination(PartyID, _C) -> ID = genlib:bsuuid(), Resource = diff --git a/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl b/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl index 64d965b6..e3d7681c 100644 --- a/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl +++ b/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl @@ -2,6 +2,7 @@ -include_lib("stdlib/include/assert.hrl"). -include_lib("damsel/include/dmsl_domain_thrift.hrl"). +-include_lib("ff_cth/include/ct_domain.hrl"). %% Common test API @@ -29,6 +30,7 @@ -export([adjustment_can_not_change_domain_revision_to_same/1]). -export([adjustment_can_not_change_domain_revision_with_failed_status/1]). -export([adjustment_can_change_domain_revision_test/1]). +-export([adjustment_can_change_cash_flow_to_guarantee_account_test/1]). -export([adjustment_fail_change_body_succeed_test/1]). -export([adjustment_can_change_body_on_succeeded_test/1]). -export([adjustment_change_cash_flow_then_change_body_test/1]). @@ -82,7 +84,8 @@ groups() -> {non_parallel, [], [ adjustment_can_change_domain_revision_test, adjustment_change_cash_flow_then_change_body_test, - adjustment_change_body_then_change_cash_flow_test + adjustment_change_body_then_change_cash_flow_test, + adjustment_can_change_cash_flow_to_guarantee_account_test ]} ]. @@ -392,6 +395,50 @@ adjustment_can_change_domain_revision_test(C) -> ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_wallet_balance(WalletID)), ?assertEqual(?FINAL_BALANCE(80, <<"RUB">>), get_destination_balance(DestinationID)). +-spec adjustment_can_change_cash_flow_to_guarantee_account_test(config()) -> test_return(). +adjustment_can_change_cash_flow_to_guarantee_account_test(C) -> + ProviderID = 1, + ProviderFee = 5, + ?FINAL_BALANCE(StartProviderAmount, <<"RUB">>) = get_provider_balance(ProviderID, ct_domain_config:head()), + #{ + withdrawal_id := WithdrawalID, + wallet_id := WalletID, + destination_id := DestinationID + } = prepare_standard_environment({100, <<"RUB">>}, C), + ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_wallet_balance(WalletID)), + ?assertEqual(?FINAL_BALANCE(80, <<"RUB">>), get_destination_balance(DestinationID)), + Withdrawal = get_withdrawal(WithdrawalID), + #{provider_id := ProviderID} = ff_withdrawal:route(Withdrawal), + DomainRevision = ff_withdrawal:domain_revision(Withdrawal), + %% provider fee is the lesser of 10 RUB and 5% of 100 RUB + ?assertEqual( + ?FINAL_BALANCE(StartProviderAmount + ProviderFee, <<"RUB">>), + get_provider_balance(ProviderID, DomainRevision) + ), + + %% switch the provider fee cash flow from the settlement to the guarantee account + GuaranteeRevision = configure_provider_guarantee_account(ProviderID), + ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_provider_balance(ProviderID, GuaranteeRevision, guarantee)), + + AdjustmentID = process_adjustment(WithdrawalID, #{ + change => {change_cash_flow, GuaranteeRevision}, + external_id => <<"true_unique_id">> + }), + ?assertMatch(succeeded, get_adjustment_status(WithdrawalID, AdjustmentID)), + ?assertEqual(succeeded, get_withdrawal_status(WithdrawalID)), + assert_adjustment_same_revisions(WithdrawalID, AdjustmentID), + %% the old settlement posting is inverted, the new one is applied to the guarantee account + ?assertEqual( + ?FINAL_BALANCE(StartProviderAmount, <<"RUB">>), + get_provider_balance(ProviderID, GuaranteeRevision, settlement) + ), + ?assertEqual( + ?FINAL_BALANCE(ProviderFee, <<"RUB">>), + get_provider_balance(ProviderID, GuaranteeRevision, guarantee) + ), + ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_wallet_balance(WalletID)), + ?assertEqual(?FINAL_BALANCE(80, <<"RUB">>), get_destination_balance(DestinationID)). + -spec adjustment_fail_change_body_succeed_test(config()) -> test_return(). adjustment_fail_change_body_succeed_test(C) -> #{ @@ -639,10 +686,32 @@ get_destination_balance(ID) -> get_account_balance(ff_destination:account(Destination)). get_provider_balance(ProviderID, DomainRevision) -> + get_provider_balance(ProviderID, DomainRevision, settlement). + +get_provider_balance(ProviderID, DomainRevision, AccountType) -> {ok, Provider} = ff_payouts_provider:get(ProviderID, DomainRevision), ProviderAccounts = ff_payouts_provider:accounts(Provider), - ProviderAccount = maps:get(<<"RUB">>, ProviderAccounts, undefined), - get_account_balance(ProviderAccount). + ProviderAccount = maps:get(<<"RUB">>, ProviderAccounts, #{}), + get_account_balance(maps:get(AccountType, ProviderAccount, undefined)). + +configure_provider_guarantee_account(ProviderID) -> + {ok, GuaranteeAccountID} = ct_helper:create_account(<<"RUB">>), + CashFlow = [ + ?cfpost( + {system, settlement}, + {provider, guarantee}, + ?fixed(5, <<"RUB">>) + ) + ], + ProviderRef = #domain_ProviderRef{id = ProviderID}, + _ = ct_domain_config:upsert( + ct_domain:withdrawal_provider_with_guarantee( + ProviderRef, + GuaranteeAccountID, + ct_domain:withdrawal_terms(<<"RUB">>, CashFlow) + ) + ), + ct_domain_config:head(). get_account_balance(Account) -> {ok, {Amounts, Currency}} = ff_accounting:balance(Account), diff --git a/apps/fistful/src/ff_cash_flow.erl b/apps/fistful/src/ff_cash_flow.erl index 208c9178..6a726693 100644 --- a/apps/fistful/src/ff_cash_flow.erl +++ b/apps/fistful/src/ff_cash_flow.erl @@ -77,7 +77,8 @@ | {wallet, receiver_destination} | {system, settlement} | {system, subagent} - | {provider, settlement}. + | {provider, settlement} + | {provider, guarantee}. -type final_account() :: #{ account := account(), diff --git a/apps/fistful/src/ff_payouts_provider.erl b/apps/fistful/src/ff_payouts_provider.erl index d657d3de..c9b65646 100644 --- a/apps/fistful/src/ff_payouts_provider.erl +++ b/apps/fistful/src/ff_payouts_provider.erl @@ -11,7 +11,12 @@ }. -type id() :: dmsl_domain_thrift:'ObjectID'(). --type accounts() :: #{ff_currency:id() => ff_account:account()}. +-type accounts() :: #{ff_currency:id() => provider_account()}. + +-type provider_account() :: #{ + settlement := ff_account:account(), + guarantee => ff_account:account() +}. -type provider_ref() :: dmsl_domain_thrift:'ProviderRef'(). -type term_set() :: dmsl_domain_thrift:'ProvisionTermSet'(). @@ -115,14 +120,30 @@ decode_accounts(Realm, Accounts) -> maps:fold( fun(CurrencyRef, ProviderAccount, Acc) -> #domain_CurrencyRef{symbolic_code = CurrencyID} = CurrencyRef, - #domain_ProviderAccount{settlement = AccountID} = ProviderAccount, - Account = ff_account:build(Realm, AccountID, CurrencyID), - Acc#{CurrencyID => Account} + Acc#{CurrencyID => decode_provider_account(ProviderAccount, CurrencyID, Realm)} end, #{}, Accounts ). +decode_provider_account( + #domain_ProviderAccount{ + settlement = SettlementID, + guarantee = GuaranteeID + }, + CurrencyID, + Realm +) -> + genlib_map:compact(#{ + settlement => decode_account(SettlementID, CurrencyID, Realm), + guarantee => decode_account(GuaranteeID, CurrencyID, Realm) + }). + +decode_account(undefined, _CurrencyID, _Realm) -> + undefined; +decode_account(AccountID, CurrencyID, Realm) -> + ff_account:build(Realm, AccountID, CurrencyID). + decode_adapter(#domain_Proxy{ref = ProxyRef, additional = ProviderOpts}) -> Proxy = unwrap(ff_domain_config:object({proxy, ProxyRef})), #domain_ProxyDefinition{ diff --git a/rebar.config b/rebar.config index b21fdf2b..8b1a80cc 100644 --- a/rebar.config +++ b/rebar.config @@ -50,7 +50,8 @@ {progressor, {git, "https://github.com/valitydev/progressor.git", {tag, "v1.0.28"}}}, {machinery, {git, "https://github.com/valitydev/machinery-erlang.git", {tag, "v1.1.22"}}}, %{fistful_proto, {git, "https://github.com/valitydev/fistful-proto.git", {branch, "master"}}}, - {fistful_proto, {git, "https://github.com/valitydev/fistful-proto.git", {branch, "ft/XYZ-467/provider-guarantee-cashflow"}}}, + {fistful_proto, + {git, "https://github.com/valitydev/fistful-proto.git", {branch, "ft/XYZ-467/provider-guarantee-cashflow"}}}, {binbase_proto, {git, "https://github.com/valitydev/binbase-proto.git", {branch, "master"}}}, {validator_personal_data_proto, {git, "https://github.com/valitydev/validator-personal-data-proto.git", {branch, "master"}}}, From 665dce82559ce757dd3561ed44baa3556fabc717 Mon Sep 17 00:00:00 2001 From: ttt161 Date: Fri, 18 Sep 2026 12:48:11 +0300 Subject: [PATCH 3/5] cleanup --- compose.yaml | 4 ++-- rebar.config | 7 ++----- rebar.lock | 4 ++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/compose.yaml b/compose.yaml index 996c6851..e2ce9635 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,7 +33,7 @@ services: command: /sbin/init dmt: - image: ghcr.io/valitydev/dominant-v2:sha-6a0a26c-epic-XYZ-467-provider-guarantee-account + image: ghcr.io/valitydev/dominant-v2:sha-93f6e4a command: /opt/dmt/bin/dmt foreground environment: DMT_KAFKA_ENABLED: "0" @@ -115,7 +115,7 @@ services: retries: 20 party-management: - image: ghcr.io/valitydev/party-management:sha-89e6278-epic-XYZ-467-provider-guaratntee-account + image: ghcr.io/valitydev/party-management:sha-2690a46 command: /opt/party-management/bin/party-management foreground volumes: - ./test/party-management/sys.config:/opt/party-management/releases/0.1/sys.config diff --git a/rebar.config b/rebar.config index 8b1a80cc..02bdf063 100644 --- a/rebar.config +++ b/rebar.config @@ -36,8 +36,7 @@ {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.2"}}}, {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {thrift, {git, "https://github.com/valitydev/thrift_erlang.git", {tag, "v1.0.0"}}}, - %{damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.46"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "ft/XYZ-467/provider-guarantee-account"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.47"}}}, {exrates_proto, {git, "https://github.com/valitydev/exrates-proto.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"}}}, @@ -49,9 +48,7 @@ {herd, {git, "https://github.com/wgnet/herd.git", {tag, "1.3.4"}}}, {progressor, {git, "https://github.com/valitydev/progressor.git", {tag, "v1.0.28"}}}, {machinery, {git, "https://github.com/valitydev/machinery-erlang.git", {tag, "v1.1.22"}}}, - %{fistful_proto, {git, "https://github.com/valitydev/fistful-proto.git", {branch, "master"}}}, - {fistful_proto, - {git, "https://github.com/valitydev/fistful-proto.git", {branch, "ft/XYZ-467/provider-guarantee-cashflow"}}}, + {fistful_proto, {git, "https://github.com/valitydev/fistful-proto.git", {branch, "master"}}}, {binbase_proto, {git, "https://github.com/valitydev/binbase-proto.git", {branch, "master"}}}, {validator_personal_data_proto, {git, "https://github.com/valitydev/validator-personal-data-proto.git", {branch, "master"}}}, diff --git a/rebar.lock b/rebar.lock index 7165f78a..4e351c94 100644 --- a/rebar.lock +++ b/rebar.lock @@ -31,7 +31,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"5fa86e3032142ebe431de109e4c00eb829489a2e"}}, + {ref,"8d6174bddedc6d9aefa407fdc1d54877b8686ff9"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git", @@ -69,7 +69,7 @@ 0}, {<<"fistful_proto">>, {git,"https://github.com/valitydev/fistful-proto.git", - {ref,"ae79e12ab44e7e354803fefc97857949258ab1d0"}}, + {ref,"50ccb60bfb17ff67c2c89501ac4d052565acc359"}}, 0}, {<<"genlib">>, {git,"https://github.com/valitydev/genlib.git", From 727efafa2c40016b276cff0441bce9f44b9871a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D0=B5=D0=BC?= Date: Fri, 18 Sep 2026 15:15:18 +0300 Subject: [PATCH 4/5] refactored --- Makefile | 2 +- apps/ff_cth/src/ct_domain.erl | 49 +--------- apps/ff_cth/src/ct_payment_system.erl | 45 +++++++++ .../test/ff_withdrawal_handler_SUITE.erl | 57 ++--------- apps/ff_transfer/src/ff_withdrawal.erl | 2 +- apps/ff_transfer/test/ff_withdrawal_SUITE.erl | 66 ++----------- .../test/ff_withdrawal_adjustment_SUITE.erl | 39 +++++--- apps/fistful/src/ff_payouts_provider.erl | 1 + apps/hellgate/src/hg_invoice_payment.erl | 35 +++++-- apps/hellgate/test/hg_ct_domain.hrl | 1 + apps/hellgate/test/hg_ct_fixture.erl | 2 +- ...rl => hg_invoice_cashflow_tests_SUITE.erl} | 95 ++++++++++++++----- 12 files changed, 199 insertions(+), 195 deletions(-) rename apps/hellgate/test/{hg_invoice_cashflow_SUITE.erl => hg_invoice_cashflow_tests_SUITE.erl} (71%) diff --git a/Makefile b/Makefile index e1748cc2..224656b3 100644 --- a/Makefile +++ b/Makefile @@ -105,7 +105,7 @@ eunit: common-test: $(REBAR) ct --cover -common-hg-test.%: apps/hellgate/test/hg_%_SUITE.erl +common-test.%: apps/hellgate/test/hg_%_tests_SUITE.erl $(REBAR) ct --cover --suite=$^ $(if $(CT_CASE),--case=$(strip $(CT_CASE))) common-ff-server-test.%: apps/ff_server/test/ff_%_SUITE.erl diff --git a/apps/ff_cth/src/ct_domain.erl b/apps/ff_cth/src/ct_domain.erl index b5fa64f7..d8a19784 100644 --- a/apps/ff_cth/src/ct_domain.erl +++ b/apps/ff_cth/src/ct_domain.erl @@ -28,8 +28,6 @@ -export([withdrawal_provider/4]). -export([withdrawal_provider/5]). -export([withdrawal_provider/6]). --export([withdrawal_terms/2]). --export([withdrawal_provider_with_guarantee/3]). -export([withdrawal_terminal/2]). -export([withdrawal_terminal/3]). @@ -123,8 +121,8 @@ create_wallet(WalletID, PartyID, Currency, TermsRef, PaymentInstRef) -> ?DTP('ProvisionTermSet') | undefined ) -> object(). withdrawal_provider(Ref, ProxyRef, Realm, TermSet) -> - {ok, AccountID} = ct_helper:create_account(<<"RUB">>), - withdrawal_provider(AccountID, Ref, ProxyRef, Realm, TermSet). + {ok, SettlementAccountID} = ct_helper:create_account(<<"RUB">>), + withdrawal_provider(SettlementAccountID, Ref, ProxyRef, Realm, TermSet). -spec withdrawal_provider( ff_account:account_id(), @@ -134,7 +132,8 @@ withdrawal_provider(Ref, ProxyRef, Realm, TermSet) -> ?DTP('ProvisionTermSet') | undefined ) -> object(). withdrawal_provider(AccountID, Ref, ProxyRef, Realm, TermSet) -> - withdrawal_provider(AccountID, undefined, Ref, ProxyRef, Realm, TermSet). + {ok, GuaranteeAccountID} = ct_helper:create_account(<<"RUB">>), + withdrawal_provider(AccountID, GuaranteeAccountID, Ref, ProxyRef, Realm, TermSet). -spec withdrawal_provider( ff_account:account_id(), @@ -162,46 +161,6 @@ withdrawal_provider(SettlementAccountID, GuaranteeAccountID, ?prv(ID) = Ref, Pro } }}. --spec withdrawal_terms(currency(), [dmsl_domain_thrift:'CashFlowPosting'()]) -> - ?DTP('ProvisionTermSet'). -withdrawal_terms(Currency, CashFlow) -> - #domain_ProvisionTermSet{ - wallet = #domain_WalletProvisionTerms{ - withdrawals = #domain_WithdrawalProvisionTerms{ - currencies = {value, ?ordset([?cur(Currency)])}, - cash_limit = - {value, - ?cashrng( - {inclusive, ?cash(0, Currency)}, - {exclusive, ?cash(10000000, Currency)} - )}, - cash_flow = {value, CashFlow} - } - } - }. - --spec withdrawal_provider_with_guarantee( - ?DTP('ProviderRef'), - ff_account:account_id(), - ?DTP('ProvisionTermSet') | undefined -) -> object(). -withdrawal_provider_with_guarantee(?prv(ID) = Ref, GuaranteeAccountID, TermSet) -> - #domain_Provider{accounts = Accounts} = Provider = ct_domain_config:get({provider, Ref}), - ProviderAccount = maps:get(?cur(<<"RUB">>), Accounts), - {provider, #domain_ProviderObject{ - ref = Ref, - data = Provider#domain_Provider{ - name = genlib:format("Withdrawal provider #~B", [ID]), - terms = TermSet, - accounts = - Accounts#{ - ?cur(<<"RUB">>) => ProviderAccount#domain_ProviderAccount{ - guarantee = GuaranteeAccountID - } - } - } - }}. - -spec withdrawal_terminal(?DTP('TerminalRef'), ?DTP('ProviderRef')) -> object(). withdrawal_terminal(Ref, ProviderRef) -> withdrawal_terminal(Ref, ProviderRef, undefined). diff --git a/apps/ff_cth/src/ct_payment_system.erl b/apps/ff_cth/src/ct_payment_system.erl index b117a849..40b1651f 100644 --- a/apps/ff_cth/src/ct_payment_system.erl +++ b/apps/ff_cth/src/ct_payment_system.erl @@ -405,6 +405,38 @@ domain_config(Config, Options) -> } } }, + GuaranteeProviderTermSet = #domain_ProvisionTermSet{ + wallet = #domain_WalletProvisionTerms{ + withdrawals = #domain_WithdrawalProvisionTerms{ + currencies = {value, ?ordset([?cur(<<"RUB">>)])}, + cash_limit = + {value, + ?cashrng( + {inclusive, ?cash(0, <<"RUB">>)}, + {exclusive, ?cash(10000000, <<"RUB">>)} + )}, + cash_flow = + {decisions, [ + #domain_CashFlowDecision{ + if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, + then_ = + {value, [ + ?cfpost( + {system, settlement}, + {provider, guarantee}, + {product, + {min_of, + ?ordset([ + ?fixed(10, <<"RUB">>), + ?share(5, 100, operation_amount, round_half_towards_zero) + ])}} + ) + ]} + } + ]} + } + } + }, Default = [ ct_domain:globals(?eas(1), [?payinst(1)]), ct_domain:external_account_set(?eas(1), <<"Default">>, ?cur(<<"RUB">>)), @@ -455,6 +487,10 @@ domain_config(Config, Options) -> routing_ruleset( ?ruleset(?PAYINST1_ROUTING_POLICIES + 4), {delegates, [ + delegate( + condition(cost_in, {800, <<"RUB">>}), + ?ruleset(?PAYINST1_ROUTING_POLICIES + 50) + ), delegate( condition(cost_in, {300, 302, <<"RUB">>}), ?ruleset(?PAYINST1_ROUTING_POLICIES + 5) @@ -754,6 +790,13 @@ domain_config(Config, Options) -> ]} ), + routing_ruleset( + ?ruleset(?PAYINST1_ROUTING_POLICIES + 50), + {candidates, [ + candidate({constant, true}, ?trm(1801)) + ]} + ), + routing_ruleset( ?ruleset(?PAYINST1_ROUTING_PROHIBITIONS), <<"PayInst1 Withdrawal Prohibitions">>, @@ -979,6 +1022,7 @@ domain_config(Config, Options) -> ct_domain:withdrawal_provider(?prv(11), ?prx(8), live, ProviderTermSet), ct_domain:withdrawal_provider(?prv(16), ?prx(2), live, undefined), ct_domain:withdrawal_provider(?prv(17), ?prx(2), live, ProviderTermSet), + ct_domain:withdrawal_provider(?prv(18), ?prx(2), live, GuaranteeProviderTermSet), ct_domain:term_set_hierarchy(?trms(1), default_termset(Options)), ct_domain:term_set_hierarchy(?trms(2), company_termset(Options)), @@ -1023,6 +1067,7 @@ domain_config(Config, Options) -> ct_domain:withdrawal_terminal(?trm(1101), ?prv(11)), ct_domain:withdrawal_terminal(?trm(1701), ?prv(17)), + ct_domain:withdrawal_terminal(?trm(1801), ?prv(18)), ct_domain:withdrawal_terminal( ?trm(1708), ?prv(17), diff --git a/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl b/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl index 070cf3e8..f870861a 100644 --- a/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl +++ b/apps/ff_server/test/ff_withdrawal_handler_SUITE.erl @@ -132,31 +132,12 @@ end_per_group(_, _) -> %% -spec init_per_testcase(test_case_name(), config()) -> config(). -init_per_testcase(withdrawal_with_provider_guarantee_account_test = Name, C) -> - C1 = ct_helper:makeup_cfg([ct_helper:test_case_name(Name), ct_helper:woody_ctx()], C), - ok = ct_helper:set_context(C1), - WasRevision = ct_domain_config:head(), - GuaranteeAccountID = configure_provider_guarantee_account(), - [ - {domain_revision, WasRevision}, - {provider_guarantee_account_id, GuaranteeAccountID} - | C1 - ]; init_per_testcase(Name, C) -> C1 = ct_helper:makeup_cfg([ct_helper:test_case_name(Name), ct_helper:woody_ctx()], C), ok = ct_helper:set_context(C1), C1. -spec end_per_testcase(test_case_name(), config()) -> _. -end_per_testcase(withdrawal_with_provider_guarantee_account_test, C) -> - _ = - case lists:keyfind(domain_revision, 1, C) of - {domain_revision, WasRevision} -> - _ = ct_domain_config:reset(WasRevision); - false -> - ok - end, - ok = ct_helper:unset_context(); end_per_testcase(_Name, _C) -> ok = ct_helper:unset_context(). @@ -959,14 +940,15 @@ withdrawal_state_content_test(_C) -> ?assertNotEqual(undefined, WithdrawalState#wthd_WithdrawalState.status). -spec withdrawal_with_provider_guarantee_account_test(config()) -> test_return(). -withdrawal_with_provider_guarantee_account_test(C) -> - Cash = make_cash({100, <<"RUB">>}), - GuaranteeAccountID = ct_helper:cfg(provider_guarantee_account_id, C), +withdrawal_with_provider_guarantee_account_test(_C) -> + %% 800 RUB is routed to provider 18 / terminal 1801, whose terms post the fee to guarantee + Cash = make_cash({800, <<"RUB">>}), + GuaranteeAccountID = provider_guarantee_account_id(18), Ctx = ct_objects:build_default_ctx(), #{withdrawal_id := WithdrawalID} = ct_objects:prepare_standard_environment(Ctx#{body => Cash}), succeeded = ct_objects:await_final_withdrawal_status(WithdrawalID), - %% the provider fee is the lesser of 10 RUB and 5% of 100 RUB, posted to the guarantee account + %% the provider fee is the lesser of 10 RUB and 5% of 800 RUB, posted to the guarantee account {ok, #wthd_WithdrawalState{ effective_final_cash_flow = #cashflow_FinalCashFlow{postings = Postings} }} = call_withdrawal('Get', {WithdrawalID, #'fistful_base_EventRange'{}}), @@ -977,7 +959,7 @@ withdrawal_with_provider_guarantee_account_test(C) -> account_type = {provider, guarantee}, account = #'account_Account'{account_id = GuaranteeAccountID} }, - volume = #fistful_base_Cash{amount = 5, currency = #'fistful_base_CurrencyRef'{symbolic_code = <<"RUB">>}} + volume = #fistful_base_Cash{amount = 10, currency = #'fistful_base_CurrencyRef'{symbolic_code = <<"RUB">>}} } ] = [ P @@ -1053,26 +1035,7 @@ make_cash({Amount, Currency}) -> currency = #'fistful_base_CurrencyRef'{symbolic_code = Currency} }. -configure_provider_guarantee_account() -> - ProviderID = 1, - {ok, GuaranteeAccountID} = ct_helper:create_account(<<"RUB">>), - CashFlow = [ - ?cfpost( - {system, settlement}, - {provider, guarantee}, - {product, - {min_of, - ?ordset([ - ?fixed(10, <<"RUB">>), - ?share(5, 100, operation_amount, round_half_towards_zero) - ])}} - ) - ], - _ = ct_domain_config:upsert( - ct_domain:withdrawal_provider_with_guarantee( - ?prv(ProviderID), - GuaranteeAccountID, - ct_domain:withdrawal_terms(<<"RUB">>, CashFlow) - ) - ), - GuaranteeAccountID. +provider_guarantee_account_id(ProviderID) -> + {ok, Provider} = ff_payouts_provider:get(ProviderID, ct_domain_config:head()), + #{guarantee := GuaranteeAccount} = maps:get(<<"RUB">>, ff_payouts_provider:accounts(Provider)), + ff_account:account_id(GuaranteeAccount). diff --git a/apps/ff_transfer/src/ff_withdrawal.erl b/apps/ff_transfer/src/ff_withdrawal.erl index 56a4ad15..266040ea 100644 --- a/apps/ff_transfer/src/ff_withdrawal.erl +++ b/apps/ff_transfer/src/ff_withdrawal.erl @@ -1121,7 +1121,7 @@ make_final_cash_flow(DomainRevision, Withdrawal) -> #{provider_id := ProviderID} = Route, {ok, Provider} = ff_payouts_provider:get(ProviderID, DomainRevision), ProviderAccounts = ff_payouts_provider:accounts(Provider), - ProviderAccount = maps:get(CurrencyID, ProviderAccounts, undefined), + ProviderAccount = maps:get(CurrencyID, ProviderAccounts, #{}), ProviderSettlementAccount = maps:get(settlement, ProviderAccount, undefined), ProviderGuaranteeAccount = maps:get(guarantee, ProviderAccount, undefined), diff --git a/apps/ff_transfer/test/ff_withdrawal_SUITE.erl b/apps/ff_transfer/test/ff_withdrawal_SUITE.erl index b6b3adb5..3c881a83 100644 --- a/apps/ff_transfer/test/ff_withdrawal_SUITE.erl +++ b/apps/ff_transfer/test/ff_withdrawal_SUITE.erl @@ -194,23 +194,6 @@ end_per_group(_, _) -> %% -spec init_per_testcase(test_case_name(), config()) -> config(). -init_per_testcase(provider_guarantee_account_test = Name, C) -> - C1 = ct_helper:makeup_cfg( - [ - ct_helper:test_case_name(Name), - ct_helper:woody_ctx() - ], - C - ), - ok = ct_helper:set_context(C1), - WasRevision = ct_domain_config:head(), - {SettlementAccountID, GuaranteeAccountID} = configure_provider_guarantee_account(), - [ - {domain_revision, WasRevision}, - {provider_settlement_account_id, SettlementAccountID}, - {provider_guarantee_account_id, GuaranteeAccountID} - | C1 - ]; init_per_testcase(Name, C) -> C1 = ct_helper:makeup_cfg( [ @@ -223,15 +206,6 @@ init_per_testcase(Name, C) -> C1. -spec end_per_testcase(test_case_name(), config()) -> _. -end_per_testcase(provider_guarantee_account_test, C) -> - _ = - case lists:keyfind(domain_revision, 1, C) of - {domain_revision, WasRevision} -> - _ = ct_domain_config:reset(WasRevision); - false -> - ok - end, - ok = ct_helper:unset_context(); end_per_testcase(_Name, _C) -> ok = ct_helper:unset_context(). @@ -830,14 +804,14 @@ withdrawal_without_termset_test(C) -> -spec provider_guarantee_account_test(config()) -> test_return(). provider_guarantee_account_test(C) -> - Cash = {300, <<"RUB">>}, + %% 800 RUB is routed to provider 18 / terminal 1801, whose terms post the fee to guarantee + Cash = {800, <<"RUB">>}, #{ wallet_id := WalletID, destination_id := DestinationID, party_id := PartyID } = prepare_standard_environment(Cash, C), - GuaranteeAccountID = ct_helper:cfg(provider_guarantee_account_id, C), - SettlementAccountID = ct_helper:cfg(provider_settlement_account_id, C), + {SettlementAccountID, GuaranteeAccountID} = provider_account_ids(18), %% the settlement account is shared between the suite cases, so only its change is asserted SettlementBefore = get_account_amount(SettlementAccountID), GuaranteeBefore = get_account_amount(GuaranteeAccountID), @@ -853,13 +827,13 @@ provider_guarantee_account_test(C) -> ok = ff_withdrawal_machine:create(WithdrawalParams, ff_entity_context:new()), ?assertEqual(succeeded, await_final_withdrawal_status(WithdrawalID)), ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_wallet_balance(WalletID)), - ?assertEqual(?FINAL_BALANCE(240, <<"RUB">>), get_destination_balance(DestinationID)), + ?assertEqual(?FINAL_BALANCE(640, <<"RUB">>), get_destination_balance(DestinationID)), Withdrawal = get_withdrawal(WithdrawalID), #{ postings := Postings } = ff_withdrawal:effective_final_cash_flow(Withdrawal), - %% the provider fee is the lesser of 10 RUB and 5% of 300 RUB, posted to the guarantee account + %% the provider fee is the lesser of 10 RUB and 5% of 800 RUB, posted to the guarantee account [ #{ sender := #{type := {system, settlement}}, @@ -1129,33 +1103,11 @@ get_account_amount(AccountID) -> {Amount, _Range, _Currency} = get_account_balance(AccountID), Amount. -configure_provider_guarantee_account() -> - ProviderID = 17, +provider_account_ids(ProviderID) -> {ok, Provider} = ff_payouts_provider:get(ProviderID, ct_domain_config:head()), - ProviderAccounts = ff_payouts_provider:accounts(Provider), - #{settlement := SettlementAccount} = maps:get(<<"RUB">>, ProviderAccounts), - SettlementAccountID = ff_account:account_id(SettlementAccount), - {ok, GuaranteeAccountID} = ct_helper:create_account(<<"RUB">>), - CashFlow = [ - ?cfpost( - {system, settlement}, - {provider, guarantee}, - {product, - {min_of, - ?ordset([ - ?fixed(10, <<"RUB">>), - ?share(5, 100, operation_amount, round_half_towards_zero) - ])}} - ) - ], - _ = ct_domain_config:upsert( - ct_domain:withdrawal_provider_with_guarantee( - ?prv(ProviderID), - GuaranteeAccountID, - ct_domain:withdrawal_terms(<<"RUB">>, CashFlow) - ) - ), - {SettlementAccountID, GuaranteeAccountID}. + #{settlement := SettlementAccount, guarantee := GuaranteeAccount} = + maps:get(<<"RUB">>, ff_payouts_provider:accounts(Provider)), + {ff_account:account_id(SettlementAccount), ff_account:account_id(GuaranteeAccount)}. create_crypto_destination(PartyID, _C) -> ID = genlib:bsuuid(), diff --git a/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl b/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl index e3d7681c..40dc5a87 100644 --- a/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl +++ b/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl @@ -398,7 +398,9 @@ adjustment_can_change_domain_revision_test(C) -> -spec adjustment_can_change_cash_flow_to_guarantee_account_test(config()) -> test_return(). adjustment_can_change_cash_flow_to_guarantee_account_test(C) -> ProviderID = 1, - ProviderFee = 5, + %% provider 1 posts a fixed 2 RUB fee to settlement + InitialProviderFee = 2, + AdjustedProviderFee = 5, ?FINAL_BALANCE(StartProviderAmount, <<"RUB">>) = get_provider_balance(ProviderID, ct_domain_config:head()), #{ withdrawal_id := WithdrawalID, @@ -410,14 +412,13 @@ adjustment_can_change_cash_flow_to_guarantee_account_test(C) -> Withdrawal = get_withdrawal(WithdrawalID), #{provider_id := ProviderID} = ff_withdrawal:route(Withdrawal), DomainRevision = ff_withdrawal:domain_revision(Withdrawal), - %% provider fee is the lesser of 10 RUB and 5% of 100 RUB ?assertEqual( - ?FINAL_BALANCE(StartProviderAmount + ProviderFee, <<"RUB">>), + ?FINAL_BALANCE(StartProviderAmount + InitialProviderFee, <<"RUB">>), get_provider_balance(ProviderID, DomainRevision) ), %% switch the provider fee cash flow from the settlement to the guarantee account - GuaranteeRevision = configure_provider_guarantee_account(ProviderID), + GuaranteeRevision = configure_provider_guarantee_cashflow(ProviderID), ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_provider_balance(ProviderID, GuaranteeRevision, guarantee)), AdjustmentID = process_adjustment(WithdrawalID, #{ @@ -433,7 +434,7 @@ adjustment_can_change_cash_flow_to_guarantee_account_test(C) -> get_provider_balance(ProviderID, GuaranteeRevision, settlement) ), ?assertEqual( - ?FINAL_BALANCE(ProviderFee, <<"RUB">>), + ?FINAL_BALANCE(AdjustedProviderFee, <<"RUB">>), get_provider_balance(ProviderID, GuaranteeRevision, guarantee) ), ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_wallet_balance(WalletID)), @@ -694,8 +695,7 @@ get_provider_balance(ProviderID, DomainRevision, AccountType) -> ProviderAccount = maps:get(<<"RUB">>, ProviderAccounts, #{}), get_account_balance(maps:get(AccountType, ProviderAccount, undefined)). -configure_provider_guarantee_account(ProviderID) -> - {ok, GuaranteeAccountID} = ct_helper:create_account(<<"RUB">>), +configure_provider_guarantee_cashflow(ProviderID) -> CashFlow = [ ?cfpost( {system, settlement}, @@ -704,12 +704,27 @@ configure_provider_guarantee_account(ProviderID) -> ) ], ProviderRef = #domain_ProviderRef{id = ProviderID}, + #domain_Provider{} = Provider = ct_domain_config:get({provider, ProviderRef}), _ = ct_domain_config:upsert( - ct_domain:withdrawal_provider_with_guarantee( - ProviderRef, - GuaranteeAccountID, - ct_domain:withdrawal_terms(<<"RUB">>, CashFlow) - ) + {provider, #domain_ProviderObject{ + ref = ProviderRef, + data = Provider#domain_Provider{ + terms = #domain_ProvisionTermSet{ + wallet = #domain_WalletProvisionTerms{ + withdrawals = #domain_WithdrawalProvisionTerms{ + currencies = {value, ?ordset([?cur(<<"RUB">>)])}, + cash_limit = + {value, + ?cashrng( + {inclusive, ?cash(0, <<"RUB">>)}, + {exclusive, ?cash(10000000, <<"RUB">>)} + )}, + cash_flow = {value, CashFlow} + } + } + } + } + }} ), ct_domain_config:head(). diff --git a/apps/fistful/src/ff_payouts_provider.erl b/apps/fistful/src/ff_payouts_provider.erl index c9b65646..974392b7 100644 --- a/apps/fistful/src/ff_payouts_provider.erl +++ b/apps/fistful/src/ff_payouts_provider.erl @@ -25,6 +25,7 @@ -export_type([id/0]). -export_type([provider/0]). +-export_type([provider_account/0]). -export_type([provider_ref/0]). -export_type([provision_terms/0]). -export_type([domain_revision/0]). diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index c650fdff..35937b48 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -2353,14 +2353,33 @@ process_cash_flow_building(_Action, St) -> allocation => Allocation, exchange_context => ExchangeContext }), - FinalCashflow = calculate_cashflow(Context, Opts), - _ = rollback_unused_payment_limits(St), - _Clock = hg_accounting:hold( - construct_payment_plan_id(St), - {1, FinalCashflow} - ), - Events = [?cash_flow_changed(FinalCashflow)], - {next, {Events, timeout}}. + try calculate_cashflow(Context, Opts) of + FinalCashflow -> + _ = rollback_unused_payment_limits(St), + _Clock = hg_accounting:hold( + construct_payment_plan_id(St), + {1, FinalCashflow} + ), + {next, {[?cash_flow_changed(FinalCashflow)], timeout}} + catch + error:{misconfiguration, _} = Error -> + %% TODO Validate route-specific cash flow accounts during routing and consider + %% cascading to another candidate when the selected route is misconfigured. + ?LOG_MD(warning, "Cash flow building failed due to misconfiguration, route: ~p, error: ~p", [ + Route, Error + ]), + Failure = construct_cashflow_failure(Error), + Routes = get_candidate_routes(St), + _ = rollback_payment_limits(Routes, get_iter(St), St, [ignore_business_error, ignore_not_found]), + {done, {[?payment_status_changed(?failed(Failure))], timeout}} + end. + +construct_cashflow_failure({misconfiguration, Details}) -> + {failure, #domain_Failure{ + code = <<"misconfiguration">>, + sub = #domain_SubFailure{code = <<"cash_flow">>}, + reason = genlib:format(Details) + }}. %% diff --git a/apps/hellgate/test/hg_ct_domain.hrl b/apps/hellgate/test/hg_ct_domain.hrl index 04952162..0c593577 100644 --- a/apps/hellgate/test/hg_ct_domain.hrl +++ b/apps/hellgate/test/hg_ct_domain.hrl @@ -47,6 +47,7 @@ -define(cashrng(Lower, Upper), #domain_CashRange{lower = Lower, upper = Upper}). -define(prvacc(Stl), #domain_ProviderAccount{settlement = Stl}). +-define(prvacc(Stl, Grt), #domain_ProviderAccount{settlement = Stl, guarantee = Grt}). -define(partycond(Ref, Def), {condition, {party, #domain_PartyCondition{party_ref = Ref, definition = Def}}}). -define(fixed(Amount, Currency), diff --git a/apps/hellgate/test/hg_ct_fixture.erl b/apps/hellgate/test/hg_ct_fixture.erl index 265d9aac..1df9124f 100644 --- a/apps/hellgate/test/hg_ct_fixture.erl +++ b/apps/hellgate/test/hg_ct_fixture.erl @@ -162,7 +162,7 @@ construct_provider_account_set(Currencies) -> ok = op_context:save(op_context:key(hellgate), op_context:create()), AccountSet = lists:foldl( fun(Cur = ?cur(Code), Acc) -> - Acc#{Cur => ?prvacc(hg_accounting:create_account(Code))} + Acc#{Cur => ?prvacc(hg_accounting:create_account(Code), hg_accounting:create_account(Code))} end, #{}, Currencies diff --git a/apps/hellgate/test/hg_invoice_cashflow_SUITE.erl b/apps/hellgate/test/hg_invoice_cashflow_tests_SUITE.erl similarity index 71% rename from apps/hellgate/test/hg_invoice_cashflow_SUITE.erl rename to apps/hellgate/test/hg_invoice_cashflow_tests_SUITE.erl index 662b88e2..aee662a8 100644 --- a/apps/hellgate/test/hg_invoice_cashflow_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_cashflow_tests_SUITE.erl @@ -1,4 +1,4 @@ --module(hg_invoice_cashflow_SUITE). +-module(hg_invoice_cashflow_tests_SUITE). -include_lib("hellgate/include/hg_invoice.hrl"). -include_lib("hellgate/include/payment_events.hrl"). @@ -19,7 +19,8 @@ %% Tests -export([payment_with_provider_settlement_account/1]). -export([payment_with_provider_guarantee_account/1]). --export([payment_undefined_provider_guarantee_accout/1]). +-export([payment_undefined_provider_guarantee_account/1]). +-export([payment_adjustment_to_provider_guarantee_account/1]). -type config() :: hg_ct_helper:config(). -type test_case_name() :: hg_ct_helper:test_case_name(). @@ -40,7 +41,8 @@ all() -> [ payment_with_provider_settlement_account, payment_with_provider_guarantee_account, - payment_undefined_provider_guarantee_accout + payment_undefined_provider_guarantee_account, + payment_adjustment_to_provider_guarantee_account ]. -spec groups() -> [{group_name(), list(), [test_case_name()]}]. @@ -113,9 +115,10 @@ init_per_testcase(Name, C) -> C1 = [{client, Client} | C], case Name of payment_with_provider_guarantee_account -> - GuaranteeAccountID = configure_provider_guarantee_account(), - [{provider_guarantee_account_id, GuaranteeAccountID} | C1]; - payment_undefined_provider_guarantee_accout -> + ok = configure_provider_guarantee_cashflow(), + C1; + payment_undefined_provider_guarantee_account -> + ok = unset_provider_guarantee_account(), ok = configure_provider_guarantee_cashflow(), C1; _ -> @@ -133,9 +136,7 @@ end_per_testcase(_, C) -> payment_with_provider_settlement_account(C) -> Amount = 42000, {CashFlow, Route} = execute_payment(Amount, C), - #domain_Provider{accounts = ProviderAccounts} = hg_domain:get({provider, ?prv(1)}), - #domain_ProviderAccount{settlement = SettlementAccountID, guarantee = undefined} = - maps:get(?cur(<<"RUB">>), ProviderAccounts), + #domain_ProviderAccount{settlement = SettlementAccountID} = provider_rub_account(), [ #domain_FinalCashFlowPosting{ source = #domain_FinalCashFlowAccount{ @@ -158,7 +159,7 @@ payment_with_provider_settlement_account(C) -> -spec payment_with_provider_guarantee_account(config()) -> test_return(). payment_with_provider_guarantee_account(C) -> Amount = 42000, - GuaranteeAccountID = cfg(provider_guarantee_account_id, C), + #domain_ProviderAccount{guarantee = GuaranteeAccountID} = provider_rub_account(), {CashFlow, Route} = execute_payment(Amount, C), [ #domain_FinalCashFlowPosting{ @@ -181,11 +182,10 @@ payment_with_provider_guarantee_account(C) -> assert_route(Route), ok. --spec payment_undefined_provider_guarantee_accout(config()) -> test_return(). -payment_undefined_provider_guarantee_accout(C) -> +-spec payment_undefined_provider_guarantee_account(config()) -> test_return(). +payment_undefined_provider_guarantee_account(C) -> Client = cfg(client, C), - #domain_Provider{accounts = ProviderAccounts} = hg_domain:get({provider, ?prv(1)}), - #domain_ProviderAccount{guarantee = undefined} = maps:get(?cur(<<"RUB">>), ProviderAccounts), + #domain_ProviderAccount{guarantee = undefined} = provider_rub_account(), InvoiceID = hg_invoice_helper:start_invoice( <<"undefined provider guarantee account">>, hg_invoice_helper:make_due_date(10), 42000, C @@ -195,16 +195,63 @@ payment_undefined_provider_guarantee_accout(C) -> Route = hg_invoice_helper:start_payment_ev(InvoiceID, Client), assert_route(Route), - %% The configured cash flow cannot be finalized without the provider guarantee account. - %% Cash-flow building fails with a misconfiguration error and emits no further payment event. - timeout = hg_invoice_helper:next_change(InvoiceID, 2000, Client), + ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure}))) = + hg_invoice_helper:next_change(InvoiceID, Client), + #domain_Failure{ + code = <<"misconfiguration">>, + sub = #domain_SubFailure{code = <<"cash_flow">>} + } = Failure, #payproc_InvoicePayment{ - payment = #domain_InvoicePayment{status = ?pending()}, + payment = #domain_InvoicePayment{status = ?failed({failure, Failure})}, route = Route, cash_flow = undefined } = hg_client_invoicing:get_payment(InvoiceID, PaymentID, Client), ok. +-spec payment_adjustment_to_provider_guarantee_account(config()) -> test_return(). +payment_adjustment_to_provider_guarantee_account(C) -> + Amount = 42000, + Client = cfg(client, C), + InvoiceID = hg_invoice_helper:start_invoice( + <<"provider cashflow adjustment">>, hg_invoice_helper:make_due_date(10), Amount, C + ), + PaymentParams = hg_invoice_helper:make_payment_params(?pmt_sys(<<"visa-ref">>)), + PaymentID = hg_invoice_helper:execute_payment(InvoiceID, PaymentParams, Client), + #payproc_InvoicePayment{route = Route, cash_flow = CashFlow} = + hg_client_invoicing:get_payment(InvoiceID, PaymentID, Client), + [_] = lookup_posting(CashFlow, {provider, settlement}, {merchant, settlement}), + assert_route(Route), + + ok = configure_provider_guarantee_cashflow(), + Params = #payproc_InvoicePaymentAdjustmentParams{ + reason = <<"switch to provider guarantee">>, + scenario = + {cash_flow, #domain_InvoicePaymentAdjustmentCashFlow{ + domain_revision = hg_domain:head() + }} + }, + ?adjustment(AdjustmentID, ?adjustment_pending()) = + Adjustment = + hg_client_invoicing:create_payment_adjustment(InvoiceID, PaymentID, Params, Client), + ?payment_ev(PaymentID, ?adjustment_ev(AdjustmentID, ?adjustment_created(Adjustment))) = + hg_invoice_helper:next_change(InvoiceID, Client), + [ + ?payment_ev(PaymentID, ?adjustment_ev(AdjustmentID, ?adjustment_status_changed(?adjustment_processed()))), + ?payment_ev(PaymentID, ?adjustment_ev(AdjustmentID, ?adjustment_status_changed(?adjustment_captured(_)))) + ] = hg_invoice_helper:next_changes(InvoiceID, 2, Client), + + #domain_InvoicePaymentAdjustment{new_cash_flow = NewCashFlow} = + hg_client_invoicing:get_payment_adjustment(InvoiceID, PaymentID, AdjustmentID, Client), + #domain_ProviderAccount{guarantee = GuaranteeAccountID} = provider_rub_account(), + [ + #domain_FinalCashFlowPosting{ + source = #domain_FinalCashFlowAccount{account_id = GuaranteeAccountID} + } + ] = lookup_posting(NewCashFlow, {provider, guarantee}, {merchant, settlement}), + #{own_amount := GuaranteeBalance} = hg_accounting:get_balance(GuaranteeAccountID), + ?assertEqual(-Amount, GuaranteeBalance), + ok. + %% Internals execute_payment(Amount, C) -> @@ -218,16 +265,18 @@ execute_payment(Amount, C) -> hg_client_invoicing:get_payment(InvoiceID, PaymentID, Client), {CashFlow, Route}. -configure_provider_guarantee_account() -> +provider_rub_account() -> + #domain_Provider{accounts = ProviderAccounts} = hg_domain:get({provider, ?prv(1)}), + maps:get(?cur(<<"RUB">>), ProviderAccounts). + +unset_provider_guarantee_account() -> Currency = ?cur(<<"RUB">>), - GuaranteeAccountID = hg_accounting:create_account(<<"RUB">>), Provider0 = #domain_Provider{accounts = Accounts0} = hg_domain:get({provider, ?prv(1)}), ProviderAccount0 = maps:get(Currency, Accounts0), - ProviderAccount1 = ProviderAccount0#domain_ProviderAccount{guarantee = GuaranteeAccountID}, + ProviderAccount1 = ProviderAccount0#domain_ProviderAccount{guarantee = undefined}, Provider1 = Provider0#domain_Provider{accounts = Accounts0#{Currency => ProviderAccount1}}, _ = hg_domain:upsert({provider, #domain_ProviderObject{ref = ?prv(1), data = Provider1}}), - ok = configure_provider_guarantee_cashflow(), - GuaranteeAccountID. + ok. configure_provider_guarantee_cashflow() -> Terminal0 = #domain_Terminal{terms = Terms0} = hg_domain:get({terminal, ?trm(1)}), From f5145e2ef925e9011f6b651094092f6ce87fa597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D0=B5=D0=BC?= Date: Fri, 18 Sep 2026 17:34:19 +0300 Subject: [PATCH 5/5] fixed --- .../test/ff_withdrawal_adjustment_SUITE.erl | 24 ++++++++++++++----- .../test/hg_invoice_cashflow_tests_SUITE.erl | 5 ++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl b/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl index 40dc5a87..920f311b 100644 --- a/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl +++ b/apps/ff_transfer/test/ff_withdrawal_adjustment_SUITE.erl @@ -398,10 +398,11 @@ adjustment_can_change_domain_revision_test(C) -> -spec adjustment_can_change_cash_flow_to_guarantee_account_test(config()) -> test_return(). adjustment_can_change_cash_flow_to_guarantee_account_test(C) -> ProviderID = 1, - %% provider 1 posts a fixed 2 RUB fee to settlement InitialProviderFee = 2, AdjustedProviderFee = 5, ?FINAL_BALANCE(StartProviderAmount, <<"RUB">>) = get_provider_balance(ProviderID, ct_domain_config:head()), + %% pin withdrawal to a domain revision with a fixed 2 RUB fee on settlement + _SettlementRevision = configure_provider_settlement_cashflow(ProviderID), #{ withdrawal_id := WithdrawalID, wallet_id := WalletID, @@ -418,7 +419,7 @@ adjustment_can_change_cash_flow_to_guarantee_account_test(C) -> ), %% switch the provider fee cash flow from the settlement to the guarantee account - GuaranteeRevision = configure_provider_guarantee_cashflow(ProviderID), + GuaranteeRevision = configure_provider_guarantee_cashflow(ProviderID, AdjustedProviderFee), ?assertEqual(?FINAL_BALANCE(0, <<"RUB">>), get_provider_balance(ProviderID, GuaranteeRevision, guarantee)), AdjustmentID = process_adjustment(WithdrawalID, #{ @@ -695,14 +696,25 @@ get_provider_balance(ProviderID, DomainRevision, AccountType) -> ProviderAccount = maps:get(<<"RUB">>, ProviderAccounts, #{}), get_account_balance(maps:get(AccountType, ProviderAccount, undefined)). -configure_provider_guarantee_cashflow(ProviderID) -> - CashFlow = [ +configure_provider_settlement_cashflow(ProviderID) -> + configure_provider_cashflow(ProviderID, [ + ?cfpost( + {system, settlement}, + {provider, settlement}, + ?fixed(2, <<"RUB">>) + ) + ]). + +configure_provider_guarantee_cashflow(ProviderID, ProviderFee) -> + configure_provider_cashflow(ProviderID, [ ?cfpost( {system, settlement}, {provider, guarantee}, - ?fixed(5, <<"RUB">>) + ?fixed(ProviderFee, <<"RUB">>) ) - ], + ]). + +configure_provider_cashflow(ProviderID, CashFlow) -> ProviderRef = #domain_ProviderRef{id = ProviderID}, #domain_Provider{} = Provider = ct_domain_config:get({provider, ProviderRef}), _ = ct_domain_config:upsert( diff --git a/apps/hellgate/test/hg_invoice_cashflow_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_cashflow_tests_SUITE.erl index aee662a8..1d1b2968 100644 --- a/apps/hellgate/test/hg_invoice_cashflow_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_cashflow_tests_SUITE.erl @@ -222,6 +222,8 @@ payment_adjustment_to_provider_guarantee_account(C) -> [_] = lookup_posting(CashFlow, {provider, settlement}, {merchant, settlement}), assert_route(Route), + #domain_ProviderAccount{guarantee = GuaranteeAccountID} = provider_rub_account(), + #{own_amount := GuaranteeBalanceBefore} = hg_accounting:get_balance(GuaranteeAccountID), ok = configure_provider_guarantee_cashflow(), Params = #payproc_InvoicePaymentAdjustmentParams{ reason = <<"switch to provider guarantee">>, @@ -242,14 +244,13 @@ payment_adjustment_to_provider_guarantee_account(C) -> #domain_InvoicePaymentAdjustment{new_cash_flow = NewCashFlow} = hg_client_invoicing:get_payment_adjustment(InvoiceID, PaymentID, AdjustmentID, Client), - #domain_ProviderAccount{guarantee = GuaranteeAccountID} = provider_rub_account(), [ #domain_FinalCashFlowPosting{ source = #domain_FinalCashFlowAccount{account_id = GuaranteeAccountID} } ] = lookup_posting(NewCashFlow, {provider, guarantee}, {merchant, settlement}), #{own_amount := GuaranteeBalance} = hg_accounting:get_balance(GuaranteeAccountID), - ?assertEqual(-Amount, GuaranteeBalance), + ?assertEqual(GuaranteeBalanceBefore - Amount, GuaranteeBalance), ok. %% Internals