From db31bee1ff8e81c18da15e4aace8c7983dfe3bfd Mon Sep 17 00:00:00 2001 From: ndiezel0 Date: Fri, 4 Sep 2026 19:06:22 +0300 Subject: [PATCH 1/5] XYZ-447: Add routing affinity and email to Customer (#6) * XYZ-447: Add routing affinity and email to Customer * Bump damsel to change deadline location --- .../0000000000002-customer-email.sql | 7 + .../0000000000003-terminal-affinity.sql | 26 ++ apps/cs/src/cs_customer.erl | 120 ++++- apps/cs/src/cs_customer_database.erl | 78 +++- apps/cs/src/cs_customer_handler.erl | 83 +++- apps/cs/src/cs_mapper.erl | 18 +- apps/cs/src/cs_terminal_affinity.erl | 143 ++++++ apps/cs/src/cs_terminal_affinity_database.erl | 175 ++++++++ apps/cs/test/cs_client.erl | 40 +- apps/cs/test/cs_ct_helper.erl | 2 +- apps/cs/test/cs_integration_SUITE.erl | 422 ++++++++++++++++++ rebar.config | 2 +- rebar.lock | 2 +- 13 files changed, 1088 insertions(+), 30 deletions(-) create mode 100644 apps/cs/priv/migrations/0000000000002-customer-email.sql create mode 100644 apps/cs/priv/migrations/0000000000003-terminal-affinity.sql create mode 100644 apps/cs/src/cs_terminal_affinity.erl create mode 100644 apps/cs/src/cs_terminal_affinity_database.erl diff --git a/apps/cs/priv/migrations/0000000000002-customer-email.sql b/apps/cs/priv/migrations/0000000000002-customer-email.sql new file mode 100644 index 0000000..38fb0bd --- /dev/null +++ b/apps/cs/priv/migrations/0000000000002-customer-email.sql @@ -0,0 +1,7 @@ +ALTER TABLE customer ADD COLUMN IF NOT EXISTS email TEXT; + +-- email is stored already normalized (lower + trim), hence a plain column index +-- rather than an expression one; partial, same as external_id +CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_email_party + ON customer(email, party_ref) + WHERE deleted_at IS NULL AND email IS NOT NULL; diff --git a/apps/cs/priv/migrations/0000000000003-terminal-affinity.sql b/apps/cs/priv/migrations/0000000000003-terminal-affinity.sql new file mode 100644 index 0000000..c1093eb --- /dev/null +++ b/apps/cs/priv/migrations/0000000000003-terminal-affinity.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS terminal_affinity ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customer(id), + provider_ref TEXT NOT NULL, + terminal_ref TEXT NOT NULL, + -- strict total order of bindings, immune to timestamp collisions + bind_seq BIGSERIAL NOT NULL, + bound_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- base for since_last_use; set on bind, bumped by every successful payment + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + released_at TIMESTAMPTZ, + released_reason TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_terminal_affinity_unique + ON terminal_affinity(customer_id, provider_ref, terminal_ref) + WHERE released_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_terminal_affinity_lookup + ON terminal_affinity(customer_id, bind_seq) + WHERE released_at IS NULL; + +-- for the admin "release everyone off terminal X" operation +CREATE INDEX IF NOT EXISTS idx_terminal_affinity_terminal + ON terminal_affinity(provider_ref, terminal_ref) + WHERE released_at IS NULL; diff --git a/apps/cs/src/cs_customer.erl b/apps/cs/src/cs_customer.erl index 1566cec..c6098fc 100644 --- a/apps/cs/src/cs_customer.erl +++ b/apps/cs/src/cs_customer.erl @@ -1,11 +1,14 @@ -module(cs_customer). -export([ - create/4, + create/5, get/1, get_state/1, get_by_external_id/2, + get_by_email/2, get_by_payment/2, + find_or_create_by_email/2, + normalize_email/1, delete/1, add_bank_card/2, remove_bank_card/2, @@ -15,7 +18,12 @@ ]). -export_type([customer_id/0, bank_card_id/0, party_ref/0, contact_info/0, metadata/0]). --export_type([customer/0, customer_state/0, payment_ref/0]). +-export_type([customer/0, customer_state/0, payment_ref/0, email/0]). + +%% Reason recorded on affinities released together with the customer +-define(DELETED_REASON, <<"customer_deleted">>). +%% Longest address an SMTP envelope may carry (RFC 5321) +-define(MAX_EMAIL_SIZE, 320). %% Types -type customer_id() :: binary(). @@ -23,6 +31,7 @@ -type party_ref() :: dmsl_domain_thrift:'PartyConfigRef'(). -type contact_info() :: dmsl_domain_thrift:'ContactInfo'() | undefined. -type metadata() :: dmsl_domain_thrift:'Metadata'() | undefined. +-type email() :: binary(). -type customer() :: #{ id := customer_id(), @@ -31,7 +40,8 @@ metadata => metadata(), created_at := binary(), deleted_at => binary() | undefined, - external_id => binary() | undefined + external_id => binary() | undefined, + email => email() | undefined }. -type customer_state() :: #{ customer := customer(), @@ -46,11 +56,18 @@ %% API --spec create(party_ref(), contact_info(), metadata(), binary() | undefined) -> - {ok, customer_id()} | {error, term()}. -create(PartyRef, ContactInfo, Metadata, ExternalID) -> - PartyRefJson = party_ref_to_json(PartyRef), - cs_customer_database:create(PartyRefJson, ContactInfo, Metadata, ExternalID). +-spec create(party_ref(), contact_info(), metadata(), binary() | undefined, email() | undefined) -> + {ok, customer_id()} | {error, external_id_conflict | email_conflict | invalid_email | term()}. +create(PartyRef, ContactInfo, Metadata, ExternalID, Email) -> + case maybe_normalize_email(Email) of + {ok, NormalizedEmail} -> + PartyRefJson = party_ref_to_json(PartyRef), + cs_customer_database:create( + PartyRefJson, ContactInfo, Metadata, ExternalID, NormalizedEmail + ); + {error, _} = Error -> + Error + end. -spec get(customer_id()) -> {ok, customer()} | {error, not_found | term()}. get(CustomerID) -> @@ -95,6 +112,33 @@ get_by_external_id(ExternalID, PartyRef) -> Error end. +-spec get_by_email(party_ref(), email()) -> + {ok, customer_state()} | {error, not_found | invalid_email | term()}. +get_by_email(PartyRef, Email) -> + case normalize_email(Email) of + {ok, NormalizedEmail} -> + PartyRefJson = party_ref_to_json(PartyRef), + case cs_customer_database:get_by_email(NormalizedEmail, PartyRefJson) of + {ok, Customer} -> + get_state(maps:get(id, Customer)); + Error -> + Error + end; + {error, _} = Error -> + Error + end. + +-spec find_or_create_by_email(party_ref(), email()) -> + {ok, customer()} | {error, invalid_email | term()}. +find_or_create_by_email(PartyRef, Email) -> + case normalize_email(Email) of + {ok, NormalizedEmail} -> + PartyRefJson = party_ref_to_json(PartyRef), + cs_customer_database:find_or_create_by_email(PartyRefJson, NormalizedEmail); + {error, _} = Error -> + Error + end. + -spec get_by_payment(binary(), binary()) -> {ok, customer_state()} | {error, not_found | invalid_recurrent_parent | term()}. get_by_payment(InvoiceId, PaymentId) -> @@ -108,9 +152,28 @@ get_by_payment(InvoiceId, PaymentId) -> Error end. +-spec normalize_email(email()) -> {ok, email()} | {error, invalid_email}. +normalize_email(Email) when is_binary(Email) -> + try unicode:characters_to_binary(string:trim(string:lowercase(Email))) of + Normalized when is_binary(Normalized) -> validate_email(Normalized); + _ -> {error, invalid_email} + catch + %% string:lowercase/1 fails with badarg on malformed UTF-8 + _:_ -> {error, invalid_email} + end; +normalize_email(_Email) -> + {error, invalid_email}. + -spec delete(customer_id()) -> ok | {error, not_found | term()}. delete(CustomerID) -> - cs_customer_database:delete(CustomerID). + case cs_customer_database:delete(CustomerID) of + ok -> + %% Orphan affinities would outlive the customer and the freed email + _ = release_terminal_affinities(CustomerID), + ok; + Error -> + Error + end. -spec add_bank_card(customer_id(), bank_card_id()) -> ok | {error, customer_not_found | term()}. add_bank_card(CustomerID, BankCardId) -> @@ -157,6 +220,45 @@ get_payments(CustomerID, Limit, Offset) -> %% Internal functions +-spec maybe_normalize_email(email() | undefined) -> {ok, email() | undefined} | {error, invalid_email}. +maybe_normalize_email(undefined) -> {ok, undefined}; +maybe_normalize_email(Email) -> normalize_email(Email). + +-spec validate_email(binary()) -> {ok, email()} | {error, invalid_email}. +validate_email(<<>>) -> + {error, invalid_email}; +%% Beyond the RFC 5321 envelope limit the value is not an address, and it also stops +%% fitting into idx_customer_email_party (btree rejects entries over ~2704 bytes) +validate_email(Email) when byte_size(Email) > ?MAX_EMAIL_SIZE -> + {error, invalid_email}; +validate_email(Email) -> + case binary:match(Email, <<"@">>) of + nomatch -> {error, invalid_email}; + _ -> validate_email_charset(Email) + end. + +%% Control characters never belong in an address, and a NUL byte cannot be stored in a +%% text column at all, so PostgreSQL would answer with an error instead of a conflict +-spec validate_email_charset(binary()) -> {ok, email()} | {error, invalid_email}. +validate_email_charset(Email) -> + ControlChars = [<> || Char <- lists:seq(0, 31)] ++ [<<127>>], + case binary:match(Email, ControlChars) of + nomatch -> {ok, Email}; + _ -> {error, invalid_email} + end. + +-spec release_terminal_affinities(customer_id()) -> ok. +release_terminal_affinities(CustomerID) -> + case cs_terminal_affinity:release_by_customer(CustomerID, ?DELETED_REASON) of + ok -> + ok; + {error, Reason} -> + logger:warning("failed to release terminal affinities of customer ~s: ~p", [ + CustomerID, Reason + ]), + ok + end. + -spec make_continuation_token(non_neg_integer(), non_neg_integer(), non_neg_integer()) -> binary() | undefined. make_continuation_token(Offset, Count, Total) -> diff --git a/apps/cs/src/cs_customer_database.erl b/apps/cs/src/cs_customer_database.erl index 23e2188..fbfb3ab 100644 --- a/apps/cs/src/cs_customer_database.erl +++ b/apps/cs/src/cs_customer_database.erl @@ -1,9 +1,11 @@ -module(cs_customer_database). -export([ - create/4, + create/5, get/1, get_by_external_id/2, + get_by_email/2, + find_or_create_by_email/2, get_by_payment/2, delete/1, link_bank_card/2, @@ -28,19 +30,21 @@ %% API --spec create(binary(), term(), term(), binary() | undefined) -> - {ok, customer_id()} | {error, term()}. -create(PartyRef, ContactInfo, Metadata, ExternalID) -> +-spec create(binary(), term(), term(), binary() | undefined, binary() | undefined) -> + {ok, customer_id()} | {error, external_id_conflict | email_conflict | term()}. +create(PartyRef, ContactInfo, Metadata, ExternalID, Email) -> Query = """ - INSERT INTO customer (party_ref, contact_info, metadata, external_id) - VALUES ($1, $2, $3, $4) + INSERT INTO customer (party_ref, contact_info, metadata, external_id, email) + VALUES ($1, $2, $3, $4, $5) RETURNING id """, - Params = [PartyRef, encode_contact_info(ContactInfo), encode_metadata(Metadata), ExternalID], + Params = [ + PartyRef, encode_contact_info(ContactInfo), encode_metadata(Metadata), ExternalID, Email + ], case epg_pool:query(?POOL, Query, Params) of {ok, _, _, [{Id}]} -> {ok, Id}; {ok, _, [{Id}]} -> {ok, Id}; - {error, #error{codename = unique_violation}} -> {error, external_id_conflict}; + {error, #error{codename = unique_violation, extra = Extra}} -> unique_violation(Extra); {error, Reason} -> {error, Reason}; Other -> {error, {unexpected_result, Other}} end. @@ -48,7 +52,7 @@ create(PartyRef, ContactInfo, Metadata, ExternalID) -> -spec get(customer_id()) -> {ok, customer()} | {error, not_found | term()}. get(CustomerID) -> Query = """ - SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id + SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email FROM customer WHERE id = $1::uuid """, @@ -63,7 +67,7 @@ get(CustomerID) -> -spec get_by_external_id(binary(), binary()) -> {ok, customer()} | {error, not_found | term()}. get_by_external_id(ExternalID, PartyRef) -> Query = """ - SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id + SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email FROM customer WHERE external_id = $1 AND party_ref = $2 @@ -78,10 +82,48 @@ get_by_external_id(ExternalID, PartyRef) -> {error, Reason} -> {error, Reason} end. +%% Email is expected to be already normalized by the business layer. +-spec get_by_email(binary(), binary()) -> {ok, customer()} | {error, not_found | term()}. +get_by_email(Email, PartyRef) -> + Query = """ + SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email + FROM customer + WHERE email = $1 + AND party_ref = $2 + AND deleted_at IS NULL + LIMIT 1 + """, + case epg_pool:query(?POOL, Query, [Email, PartyRef]) of + {ok, _, _, [Row]} -> {ok, row_to_customer(Row)}; + {ok, _, _, []} -> {error, not_found}; + {ok, _, [Row]} -> {ok, row_to_customer(Row)}; + {ok, _, []} -> {error, not_found}; + {error, Reason} -> {error, Reason} + end. + +-spec find_or_create_by_email(binary(), binary()) -> {ok, customer()} | {error, term()}. +find_or_create_by_email(PartyRef, Email) -> + %% ON CONFLICT predicate is literally the predicate of idx_customer_email_party, + %% otherwise PostgreSQL fails to infer the arbiter index (42P10). The DO UPDATE is + %% a no-op needed to make RETURNING fire for an already existing row as well. + Query = """ + INSERT INTO customer (party_ref, email) + VALUES ($1, $2) + ON CONFLICT (email, party_ref) WHERE deleted_at IS NULL AND email IS NOT NULL + DO UPDATE SET email = EXCLUDED.email + RETURNING id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email + """, + case query_rows(?POOL, Query, [PartyRef, Email]) of + {ok, [Row]} -> {ok, row_to_customer(Row)}; + {ok, []} -> {error, failed_to_create}; + {error, Reason} -> {error, Reason} + end. + -spec get_by_payment(invoice_id(), payment_id()) -> {ok, customer()} | {error, not_found | term()}. get_by_payment(InvoiceId, PaymentId) -> Query = """ - SELECT c.id, c.party_ref, c.contact_info, c.metadata, c.created_at, c.deleted_at, c.external_id + SELECT c.id, c.party_ref, c.contact_info, c.metadata, c.created_at, c.deleted_at, + c.external_id, c.email FROM customer c JOIN payment_ref pr ON c.id = pr.customer_id WHERE pr.invoice_id = $1 @@ -191,7 +233,7 @@ get_payments(CustomerID, Limit, Offset) -> %% Internal functions -row_to_customer({Id, PartyRef, ContactInfo, Metadata, CreatedAt, DeletedAt, ExternalID}) -> +row_to_customer({Id, PartyRef, ContactInfo, Metadata, CreatedAt, DeletedAt, ExternalID, Email}) -> #{ id => Id, party_ref => PartyRef, @@ -199,9 +241,19 @@ row_to_customer({Id, PartyRef, ContactInfo, Metadata, CreatedAt, DeletedAt, Exte metadata => decode_metadata(Metadata), created_at => CreatedAt, deleted_at => null_to_default(DeletedAt, undefined), - external_id => null_to_default(ExternalID, undefined) + external_id => null_to_default(ExternalID, undefined), + email => null_to_default(Email, undefined) }. +%% Both partial unique indexes on customer are reachable from create/5, so the +%% conflicting one is told apart by constraint name. +unique_violation(Extra) -> + case proplists:get_value(constraint_name, Extra) of + <<"idx_customer_external_id_party">> -> {error, external_id_conflict}; + <<"idx_customer_email_party">> -> {error, email_conflict}; + Name -> {error, {unique_violation, Name}} + end. + null_to_default(null, Default) -> Default; null_to_default(V, _Default) -> V. diff --git a/apps/cs/src/cs_customer_handler.erl b/apps/cs/src/cs_customer_handler.erl index 92df29e..1842807 100644 --- a/apps/cs/src/cs_customer_handler.erl +++ b/apps/cs/src/cs_customer_handler.erl @@ -1,6 +1,7 @@ -module(cs_customer_handler). -include_lib("damsel/include/dmsl_customer_thrift.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). -export([handle_function/4]). @@ -18,9 +19,10 @@ do_handle_function('Create', {Params}, _Context, _Options) -> party_ref = PartyRef, contact_info = ContactInfo, metadata = Metadata, - external_id = ExternalID + external_id = ExternalID, + email = Email } = Params, - case cs_customer:create(PartyRef, ContactInfo, Metadata, ExternalID) of + case cs_customer:create(PartyRef, ContactInfo, Metadata, ExternalID, Email) of {ok, CustomerID} -> {ok, CustomerState} = cs_customer:get_state(CustomerID), {ok, cs_mapper:customer_to_thrift(maps:get(customer, CustomerState))}; @@ -28,6 +30,12 @@ do_handle_function('Create', {Params}, _Context, _Options) -> woody_error:raise(business, #customer_CustomerAlreadyExists{ id = get_existing_customer_id(ExternalID, PartyRef) }); + {error, email_conflict} -> + woody_error:raise(business, #customer_CustomerEmailConflict{ + id = get_existing_customer_id_by_email(PartyRef, Email) + }); + {error, invalid_email} -> + woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid email">>]}); {error, Reason} -> woody_error:raise(system, {internal, Reason}) end; @@ -161,6 +169,71 @@ do_handle_function('GetBankCards', {CustomerID, Limit, ContinuationToken}, _Cont woody_error:raise(business, #customer_CustomerNotFound{}); {error, Reason} -> woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('FindOrCreateByEmail', {PartyRef, Email}, _Context, _Options) -> + case cs_customer:find_or_create_by_email(PartyRef, Email) of + {ok, Customer} -> + {ok, cs_mapper:customer_to_thrift(Customer)}; + {error, invalid_email} -> + woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid email">>]}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('GetByEmail', {PartyRef, Email}, _Context, _Options) -> + case cs_customer:get_by_email(PartyRef, Email) of + {ok, State} -> + {ok, cs_mapper:customer_state_to_thrift(State)}; + {error, Reason} when Reason =:= not_found; Reason =:= invalid_email -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('GetTerminalAffinities', {CustomerID}, _Context, _Options) -> + case cs_terminal_affinity:list(CustomerID) of + {ok, Affinities} -> + {ok, [cs_mapper:terminal_affinity_to_thrift(A) || A <- Affinities]}; + {error, not_found} -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('BindTerminalAffinity', {Params}, _Context, _Options) -> + #customer_TerminalAffinityParams{ + customer_id = CustomerID, + provider_ref = ProviderRef, + terminal_ref = TerminalRef, + ttl = Ttl + } = Params, + case cs_terminal_affinity:bind(CustomerID, ProviderRef, TerminalRef, Ttl) of + {ok, Affinity} -> + {ok, cs_mapper:terminal_affinity_to_thrift(Affinity)}; + {error, not_found} -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, invalid_request} -> + woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid ttl">>]}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('ReleaseTerminalAffinity', {Params}, _Context, _Options) -> + #customer_ReleaseTerminalAffinityParams{ + customer_id = CustomerID, + key = Key, + reason = Reason + } = Params, + case cs_terminal_affinity:release(CustomerID, Key, Reason) of + ok -> + {ok, ok}; + {error, not_found} -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, Error} -> + woody_error:raise(system, {internal, Error}) + end; +do_handle_function('ReleaseTerminalAffinitiesByTerminal', {Key, Reason}, _Context, _Options) -> + case cs_terminal_affinity:release_by_terminal(Key, Reason) of + ok -> + {ok, ok}; + {error, Error} -> + woody_error:raise(system, {internal, Error}) end. %% Internal functions @@ -175,6 +248,12 @@ get_existing_customer_id(ExternalID, PartyRef) -> {error, not_found} -> undefined end. +get_existing_customer_id_by_email(PartyRef, Email) -> + case cs_customer:get_by_email(PartyRef, Email) of + {ok, #{customer := Customer}} -> maps:get(id, Customer); + {error, _} -> undefined + end. + get_bank_card_info(BankCardId) -> case cs_bank_card:get_info(BankCardId) of {ok, BankCardInfo} -> {true, cs_mapper:bank_card_info_to_thrift(BankCardInfo)}; diff --git a/apps/cs/src/cs_mapper.erl b/apps/cs/src/cs_mapper.erl index fc30c42..baa8405 100644 --- a/apps/cs/src/cs_mapper.erl +++ b/apps/cs/src/cs_mapper.erl @@ -10,7 +10,8 @@ bank_card_with_tokens_to_thrift/1, bank_card_info_to_thrift/1, payment_to_thrift/1, - recurrent_token_to_thrift/1 + recurrent_token_to_thrift/1, + terminal_affinity_to_thrift/1 ]). -export_type([ @@ -20,6 +21,7 @@ bank_card_with_tokens/0, bank_card_info/0, recurrent_token/0, + terminal_affinity/0, tokens_map/0 ]). @@ -30,6 +32,7 @@ -type bank_card_with_tokens() :: cs_bank_card:bank_card_with_tokens(). -type bank_card_info() :: cs_bank_card:bank_card_info(). -type recurrent_token() :: cs_bank_card_database:recurrent_token(). +-type terminal_affinity() :: cs_terminal_affinity:affinity(). -type tokens_map() :: #{ dmsl_customer_thrift:'ProviderTerminalKey'() => dmsl_customer_thrift:'RecurrentToken'() }. @@ -45,7 +48,8 @@ customer_to_thrift(Customer) -> status = customer_status(Customer), contact_info = maps:get(contact_info, Customer, undefined), metadata = maps:get(metadata, Customer, undefined), - external_id = maps:get(external_id, Customer, undefined) + external_id = maps:get(external_id, Customer, undefined), + email = maps:get(email, Customer, undefined) }. -spec customer_state_to_thrift(customer_state()) -> dmsl_customer_thrift:'CustomerState'(). @@ -125,6 +129,16 @@ recurrent_token_to_thrift(Token) -> status = recurrent_token_status(Token) }. +-spec terminal_affinity_to_thrift(terminal_affinity()) -> dmsl_customer_thrift:'TerminalAffinity'(). +terminal_affinity_to_thrift(Affinity) -> + #customer_TerminalAffinity{ + provider_ref = binary_to_provider_ref(maps:get(provider_ref, Affinity)), + terminal_ref = binary_to_terminal_ref(maps:get(terminal_ref, Affinity)), + bind_seq = maps:get(bind_seq, Affinity), + bound_at = format_timestamp_required(maps:get(bound_at, Affinity)), + last_used_at = format_timestamp_required(maps:get(last_used_at, Affinity)) + }. + %% Internal functions -spec customer_status(customer()) -> dmsl_customer_thrift:'CustomerStatus'(). diff --git a/apps/cs/src/cs_terminal_affinity.erl b/apps/cs/src/cs_terminal_affinity.erl new file mode 100644 index 0000000..b8fae8d --- /dev/null +++ b/apps/cs/src/cs_terminal_affinity.erl @@ -0,0 +1,143 @@ +-module(cs_terminal_affinity). + +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). + +-export([ + list/1, + bind/4, + release/3, + release_by_customer/2, + release_by_terminal/2 +]). + +-export_type([ + affinity/0, + cutoff/0, + customer_id/0, + provider_ref/0, + terminal_ref/0, + provider_terminal_key/0, + reason/0, + ttl/0 +]). + +%% Types +-type customer_id() :: cs_customer:customer_id(). +-type provider_ref() :: dmsl_domain_thrift:'ProviderRef'(). +-type terminal_ref() :: dmsl_domain_thrift:'TerminalRef'(). +-type provider_terminal_key() :: dmsl_customer_thrift:'ProviderTerminalKey'(). +-type ttl() :: dmsl_domain_thrift:'RoutingAffinityTtl'() | undefined. +-type reason() :: binary() | undefined. + +-type affinity() :: cs_terminal_affinity_database:affinity(). +%% Expiration base and the timestamp an affinity is expired when its base precedes +-type cutoff() :: cs_terminal_affinity_database:cutoff(). + +%% API + +-spec list(customer_id()) -> {ok, [affinity()]} | {error, not_found | term()}. +list(CustomerID) -> + case cs_customer:get(CustomerID) of + {ok, _Customer} -> + cs_terminal_affinity_database:list(CustomerID); + Error -> + Error + end. + +-spec bind(customer_id(), provider_ref(), terminal_ref(), ttl()) -> + {ok, affinity()} | {error, not_found | invalid_request | term()}. +bind(CustomerID, ProviderRef, TerminalRef, Ttl) -> + case ttl_to_cutoff(Ttl) of + {ok, Cutoff} -> + case cs_customer:get(CustomerID) of + {ok, _Customer} -> + {ProviderRefJson, TerminalRefJson} = refs_to_json(ProviderRef, TerminalRef), + cs_terminal_affinity_database:bind( + CustomerID, ProviderRefJson, TerminalRefJson, Cutoff + ); + Error -> + Error + end; + {error, _} = Error -> + Error + end. + +-spec release(customer_id(), provider_terminal_key(), reason()) -> ok | {error, not_found | term()}. +release(CustomerID, #customer_ProviderTerminalKey{} = Key, Reason) -> + case cs_customer:get(CustomerID) of + {ok, _Customer} -> + {ProviderRefJson, TerminalRefJson} = key_to_json(Key), + Result = cs_terminal_affinity_database:release( + CustomerID, ProviderRefJson, TerminalRefJson, Reason + ), + %% The customer exists, so an absent active affinity means the release + %% already happened: the operation is idempotent, not an error. Only a + %% missing customer is reported as not_found (mapped to CustomerNotFound). + case Result of + {error, not_found} -> ok; + Other -> Other + end; + Error -> + Error + end. + +-spec release_by_customer(customer_id(), reason()) -> ok | {error, term()}. +release_by_customer(CustomerID, Reason) -> + case cs_terminal_affinity_database:release_by_customer(CustomerID, Reason) of + {ok, _Count} -> ok; + Error -> Error + end. + +-spec release_by_terminal(provider_terminal_key(), reason()) -> ok | {error, term()}. +release_by_terminal(#customer_ProviderTerminalKey{} = Key, Reason) -> + {ProviderRefJson, TerminalRefJson} = key_to_json(Key), + case cs_terminal_affinity_database:release_by_terminal(ProviderRefJson, TerminalRefJson, Reason) of + {ok, _Count} -> ok; + Error -> Error + end. + +%% Internal functions + +%% Every ttl variant names the moment an affinity expires; here it is turned into +%% what the database layer compares: a cutoff for a base column, or a verdict that +%% the active affinity is already expired regardless of its bases. +-spec ttl_to_cutoff(ttl()) -> {ok, cutoff()} | {error, invalid_request}. +ttl_to_cutoff(undefined) -> + {ok, undefined}; +ttl_to_cutoff({Base, Timeout}) when + (Base =:= since_bound orelse Base =:= since_last_use), is_integer(Timeout), Timeout >= 0 +-> + Cutoff = erlang:system_time(second) - Timeout, + {ok, {Base, list_to_binary(calendar:system_time_to_rfc3339(Cutoff, [{offset, "Z"}]))}}; +ttl_to_cutoff({deadline, Deadline}) when is_binary(Deadline) -> + %% Parsed here rather than left to the PostgreSQL timestamptz parser: the latter + %% turns a malformed deadline into a transaction error (a system error to the + %% caller instead of the declared InvalidRequest) and silently accepts the special + %% values 'now' / 'infinity' / 'yesterday', which would expire every affinity. + %% An absolute deadline is indifferent to bound_at / last_used_at: once it has + %% passed, the active affinity is expired, whatever its bases say. + try calendar:rfc3339_to_system_time(binary_to_list(Deadline), [{unit, microsecond}]) of + Micro -> + case Micro =< erlang:system_time(microsecond) of + true -> {ok, expired}; + false -> {ok, undefined} + end + catch + _:_ -> {error, invalid_request} + end; +ttl_to_cutoff(_Ttl) -> + {error, invalid_request}. + +-spec key_to_json(provider_terminal_key()) -> {binary(), binary()}. +key_to_json(#customer_ProviderTerminalKey{provider_ref = ProviderRef, terminal_ref = TerminalRef}) -> + refs_to_json(ProviderRef, TerminalRef). + +-spec refs_to_json(provider_ref(), terminal_ref()) -> {binary(), binary()}. +refs_to_json(ProviderRef, TerminalRef) -> + ProviderType = {struct, struct, {dmsl_domain_thrift, 'ProviderRef'}}, + TerminalType = {struct, struct, {dmsl_domain_thrift, 'TerminalRef'}}, + {ref_to_json(ProviderRef, ProviderType), ref_to_json(TerminalRef, TerminalType)}. + +-spec ref_to_json(tuple(), cs_json:thrift_type()) -> binary(). +ref_to_json(Ref, Type) -> + cs_json:encode(cs_json:term_to_json(Ref, Type)). diff --git a/apps/cs/src/cs_terminal_affinity_database.erl b/apps/cs/src/cs_terminal_affinity_database.erl new file mode 100644 index 0000000..a8b382d --- /dev/null +++ b/apps/cs/src/cs_terminal_affinity_database.erl @@ -0,0 +1,175 @@ +-module(cs_terminal_affinity_database). + +-export([ + list/1, + bind/4, + release/4, + release_by_customer/2, + release_by_terminal/3 +]). + +-define(POOL, default_pool). + +%% Types +-type customer_id() :: cs_customer_database:customer_id(). +-type affinity() :: #{ + provider_ref := binary(), + terminal_ref := binary(), + bind_seq := integer(), + bound_at := term(), + last_used_at := term() +}. +%% What bind/4 releases before writing: nothing, the active affinity whatever its +%% bases (an absolute deadline has passed), or the one whose base column precedes +%% the cutoff timestamp. +-type cutoff() :: undefined | expired | {since_bound | since_last_use, binary()}. + +-export_type([customer_id/0, affinity/0, cutoff/0]). + +%% API + +-spec list(customer_id()) -> {ok, [affinity()]} | {error, term()}. +list(CustomerID) -> + Query = """ + SELECT provider_ref, terminal_ref, bind_seq, bound_at, last_used_at + FROM terminal_affinity + WHERE customer_id = $1::uuid + AND released_at IS NULL + ORDER BY bind_seq ASC + """, + case query_rows(?POOL, Query, [CustomerID]) of + {ok, Rows} -> {ok, [row_to_affinity(Row) || Row <- Rows]}; + {error, Reason} -> {error, Reason} + end. + +-spec bind(customer_id(), binary(), binary(), cutoff()) -> {ok, affinity()} | {error, term()}. +bind(CustomerID, ProviderRef, TerminalRef, Cutoff) -> + %% Two statements in a single transaction rather than a data-modifying CTE: CTE + %% branches share one snapshot and would not see each other's effects. + Fun = fun(Conn) -> + ok = release_expired(Conn, CustomerID, ProviderRef, TerminalRef, Cutoff), + upsert(Conn, CustomerID, ProviderRef, TerminalRef) + end, + case epg_pool:transaction(?POOL, Fun) of + {ok, _Affinity} = Result -> Result; + {error, Reason} -> {error, Reason}; + {rollback, {?MODULE, Reason}} -> {error, Reason}; + {rollback, Reason} -> {error, Reason} + end. + +-spec release(customer_id(), binary(), binary(), binary() | undefined) -> + ok | {error, not_found | term()}. +release(CustomerID, ProviderRef, TerminalRef, Reason) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = $4 + WHERE customer_id = $1::uuid + AND provider_ref = $2 + AND terminal_ref = $3 + AND released_at IS NULL + """, + case epg_pool:query(?POOL, Query, [CustomerID, ProviderRef, TerminalRef, Reason]) of + {ok, N} when N > 0 -> ok; + {ok, 0} -> {error, not_found}; + {error, Error} -> {error, Error} + end. + +-spec release_by_customer(customer_id(), binary() | undefined) -> + {ok, non_neg_integer()} | {error, term()}. +release_by_customer(CustomerID, Reason) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = $2 + WHERE customer_id = $1::uuid + AND released_at IS NULL + """, + case epg_pool:query(?POOL, Query, [CustomerID, Reason]) of + {ok, N} -> {ok, N}; + {error, Error} -> {error, Error} + end. + +-spec release_by_terminal(binary(), binary(), binary() | undefined) -> + {ok, non_neg_integer()} | {error, term()}. +release_by_terminal(ProviderRef, TerminalRef, Reason) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = $3 + WHERE provider_ref = $1 + AND terminal_ref = $2 + AND released_at IS NULL + """, + case epg_pool:query(?POOL, Query, [ProviderRef, TerminalRef, Reason]) of + {ok, N} -> {ok, N}; + {error, Error} -> {error, Error} + end. + +%% Internal functions + +release_expired(_Conn, _CustomerID, _ProviderRef, _TerminalRef, undefined) -> + ok; +release_expired(Conn, CustomerID, ProviderRef, TerminalRef, expired) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = 'expired' + WHERE customer_id = $1::uuid + AND provider_ref = $2 + AND terminal_ref = $3 + AND released_at IS NULL + """, + case epg_pool:query(Conn, Query, [CustomerID, ProviderRef, TerminalRef]) of + {ok, _} -> ok; + {error, Reason} -> rollback(Reason) + end; +release_expired(Conn, CustomerID, ProviderRef, TerminalRef, {Base, CutoffAt}) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = 'expired' + WHERE customer_id = $1::uuid + AND provider_ref = $2 + AND terminal_ref = $3 + AND released_at IS NULL + AND (CASE $5::text WHEN 'since_bound' THEN bound_at ELSE last_used_at END) + < $4::text::timestamptz + """, + Params = [CustomerID, ProviderRef, TerminalRef, CutoffAt, atom_to_binary(Base, utf8)], + case epg_pool:query(Conn, Query, Params) of + {ok, _} -> ok; + {error, Reason} -> rollback(Reason) + end. + +upsert(Conn, CustomerID, ProviderRef, TerminalRef) -> + %% ON CONFLICT predicate is literally the predicate of idx_terminal_affinity_unique + Query = """ + INSERT INTO terminal_affinity (customer_id, provider_ref, terminal_ref) + VALUES ($1::uuid, $2, $3) + ON CONFLICT (customer_id, provider_ref, terminal_ref) WHERE released_at IS NULL + DO UPDATE SET last_used_at = NOW() + RETURNING provider_ref, terminal_ref, bind_seq, bound_at, last_used_at + """, + case query_rows(Conn, Query, [CustomerID, ProviderRef, TerminalRef]) of + {ok, [Row]} -> {ok, row_to_affinity(Row)}; + {ok, []} -> rollback(failed_to_bind); + {error, Reason} -> rollback(Reason) + end. + +%% Only a raised exception rolls the transaction back; a plain {error, _} return +%% would be committed by epgsql:with_transaction/3. +-spec rollback(term()) -> no_return(). +rollback(Reason) -> + erlang:error({?MODULE, Reason}). + +row_to_affinity({ProviderRef, TerminalRef, BindSeq, BoundAt, LastUsedAt}) -> + #{ + provider_ref => ProviderRef, + terminal_ref => TerminalRef, + bind_seq => BindSeq, + bound_at => BoundAt, + last_used_at => LastUsedAt + }. + +query_rows(PoolOrConn, Query, Params) -> + case epg_pool:query(PoolOrConn, Query, Params) of + {ok, _, _, Rows} -> {ok, Rows}; + {ok, _, Rows} -> {ok, Rows}; + {error, Reason} -> {error, Reason} + end. diff --git a/apps/cs/test/cs_client.erl b/apps/cs/test/cs_client.erl index fdbfcdf..4e15e96 100644 --- a/apps/cs/test/cs_client.erl +++ b/apps/cs/test/cs_client.erl @@ -14,7 +14,13 @@ remove_bank_card/3, add_payment/4, get_payments/4, - get_bank_cards/4 + get_bank_cards/4, + find_or_create_customer_by_email/3, + get_customer_by_email/3, + get_terminal_affinities/2, + bind_terminal_affinity/2, + release_terminal_affinity/2, + release_terminal_affinities_by_terminal/3 ]). %% BankCardStorage API @@ -79,6 +85,38 @@ get_payments(CustomerID, Limit, ContinuationToken, Client) -> get_bank_cards(CustomerID, Limit, ContinuationToken, Client) -> cs_client_api:call(customer_management, 'GetBankCards', [CustomerID, Limit, ContinuationToken], Client). +-spec find_or_create_customer_by_email(dmsl_domain_thrift:'PartyConfigRef'(), binary(), cs_client_api:t()) -> + {ok, dmsl_customer_thrift:'Customer'()} | {exception, term()} | {error, term()}. +find_or_create_customer_by_email(PartyRef, Email, Client) -> + cs_client_api:call(customer_management, 'FindOrCreateByEmail', [PartyRef, Email], Client). + +-spec get_customer_by_email(dmsl_domain_thrift:'PartyConfigRef'(), binary(), cs_client_api:t()) -> + {ok, dmsl_customer_thrift:'CustomerState'()} | {exception, term()} | {error, term()}. +get_customer_by_email(PartyRef, Email, Client) -> + cs_client_api:call(customer_management, 'GetByEmail', [PartyRef, Email], Client). + +-spec get_terminal_affinities(binary(), cs_client_api:t()) -> + {ok, [dmsl_customer_thrift:'TerminalAffinity'()]} | {exception, term()} | {error, term()}. +get_terminal_affinities(CustomerID, Client) -> + cs_client_api:call(customer_management, 'GetTerminalAffinities', [CustomerID], Client). + +-spec bind_terminal_affinity(dmsl_customer_thrift:'TerminalAffinityParams'(), cs_client_api:t()) -> + {ok, dmsl_customer_thrift:'TerminalAffinity'()} | {exception, term()} | {error, term()}. +bind_terminal_affinity(Params, Client) -> + cs_client_api:call(customer_management, 'BindTerminalAffinity', [Params], Client). + +-spec release_terminal_affinity(dmsl_customer_thrift:'ReleaseTerminalAffinityParams'(), cs_client_api:t()) -> + {ok, ok} | {exception, term()} | {error, term()}. +release_terminal_affinity(Params, Client) -> + cs_client_api:call(customer_management, 'ReleaseTerminalAffinity', [Params], Client). + +-spec release_terminal_affinities_by_terminal( + dmsl_customer_thrift:'ProviderTerminalKey'(), binary() | undefined, cs_client_api:t() +) -> + {ok, ok} | {exception, term()} | {error, term()}. +release_terminal_affinities_by_terminal(Key, Reason, Client) -> + cs_client_api:call(customer_management, 'ReleaseTerminalAffinitiesByTerminal', [Key, Reason], Client). + %% BankCardStorage -spec get_bank_card(binary(), cs_client_api:t()) -> diff --git a/apps/cs/test/cs_ct_helper.erl b/apps/cs/test/cs_ct_helper.erl index 3110003..3094853 100644 --- a/apps/cs/test/cs_ct_helper.erl +++ b/apps/cs/test/cs_ct_helper.erl @@ -64,7 +64,7 @@ start_app(epg_connector = AppName) -> start_app(AppName, [ {databases, #{ cs => #{ - host => "cs_db", + host => os:getenv("POSTGRES_HOST", "cs_db"), port => 5432, username => "postgres", password => "postgres", diff --git a/apps/cs/test/cs_integration_SUITE.erl b/apps/cs/test/cs_integration_SUITE.erl index 5c389fb..99fbf8e 100644 --- a/apps/cs/test/cs_integration_SUITE.erl +++ b/apps/cs/test/cs_integration_SUITE.erl @@ -2,9 +2,13 @@ -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). -include_lib("damsel/include/dmsl_customer_thrift.hrl"). -include_lib("damsel/include/dmsl_domain_thrift.hrl"). +%% Age, in seconds, an affinity is backdated to before a one day TTL is applied to it +-define(TEN_DAYS, 10 * 24 * 60 * 60). + -export([ all/0, groups/0, @@ -32,15 +36,39 @@ get_by_external_id_test/1, get_by_external_id_not_found_test/1, create_customer_external_id_conflict_test/1, + find_or_create_by_email_test/1, + find_or_create_by_email_concurrent_test/1, + find_or_create_by_email_normalization_test/1, + find_or_create_by_email_invalid_test/1, + create_customer_email_conflict_test/1, + create_customer_email_other_party_test/1, + get_by_email_test/1, + get_by_email_not_found_test/1, create_bank_card_test/1, find_bank_card_test/1, add_recurrent_token_test/1, invalidate_recurrent_token_test/1 ]). +-export([ + terminal_affinities_empty_test/1, + terminal_affinities_customer_not_found_test/1, + bind_terminal_affinity_test/1, + bind_terminal_affinity_idempotent_test/1, + bind_terminal_affinity_ttl_since_bound_test/1, + bind_terminal_affinity_ttl_since_last_use_test/1, + bind_terminal_affinity_ttl_since_last_use_expires_test/1, + bind_terminal_affinity_ttl_deadline_test/1, + bind_terminal_affinity_invalid_ttl_test/1, + release_terminal_affinity_test/1, + release_terminal_affinities_by_terminal_test/1, + delete_customer_releases_affinities_test/1 +]). + all() -> [ {group, customer_management}, + {group, terminal_affinity}, {group, bank_card_storage} ]. @@ -61,10 +89,32 @@ groups() -> get_by_external_id_test, get_by_external_id_not_found_test, create_customer_external_id_conflict_test, + find_or_create_by_email_test, + find_or_create_by_email_concurrent_test, + find_or_create_by_email_normalization_test, + find_or_create_by_email_invalid_test, + create_customer_email_conflict_test, + create_customer_email_other_party_test, + get_by_email_test, + get_by_email_not_found_test, remove_bank_card_test, delete_customer_test, customer_not_found_test ]}, + {terminal_affinity, [parallel], [ + terminal_affinities_empty_test, + terminal_affinities_customer_not_found_test, + bind_terminal_affinity_test, + bind_terminal_affinity_idempotent_test, + bind_terminal_affinity_ttl_since_bound_test, + bind_terminal_affinity_ttl_since_last_use_test, + bind_terminal_affinity_ttl_since_last_use_expires_test, + bind_terminal_affinity_ttl_deadline_test, + bind_terminal_affinity_invalid_ttl_test, + release_terminal_affinity_test, + release_terminal_affinities_by_terminal_test, + delete_customer_releases_affinities_test + ]}, {bank_card_storage, [parallel], [ create_bank_card_test, find_bank_card_test, @@ -395,6 +445,304 @@ create_customer_external_id_conflict_test(Config) -> ?assertEqual(CustomerID, ConflictID), ok. +%% Repeated find-or-create by the same email resolves to a single customer +find_or_create_by_email_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-find-or-create">>}, + Email = <<"find-or-create@affinity.test">>, + {ok, Customer1} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + {ok, Customer2} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + ?assertEqual(Customer1#customer_Customer.id, Customer2#customer_Customer.id), + ?assertEqual(Email, Customer1#customer_Customer.email), + ?assertEqual(PartyRef, Customer1#customer_Customer.party_ref), + ?assertEqual(1, count_customers_by_email(Email)), + ok. + +%% Concurrent find-or-create is arbitrated by the unique index, not by the caller +find_or_create_by_email_concurrent_test(_Config) -> + PartyRef = #domain_PartyConfigRef{id = <<"party-email-concurrent">>}, + Email = <<"concurrent@affinity.test">>, + Self = self(), + Pids = [ + erlang:spawn_link(fun() -> + Client = cs_ct_helper:create_client(), + Self ! {self(), cs_client:find_or_create_customer_by_email(PartyRef, Email, Client)} + end) + || _ <- lists:seq(1, 5) + ], + Ids = [ + receive + {Pid, {ok, Customer}} -> Customer#customer_Customer.id + after 30000 -> error({timeout, Pid}) + end + || Pid <- Pids + ], + ?assertEqual(1, length(lists:usort(Ids))), + ?assertEqual(1, count_customers_by_email(Email)), + ok. + +%% Email is normalized (trimmed and lowercased) both on write and on lookup +find_or_create_by_email_normalization_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-normalization">>}, + Normalized = <<"user@norm.test">>, + {ok, Customer1} = cs_client:find_or_create_customer_by_email(PartyRef, <<" User@Norm.Test ">>, Client), + {ok, Customer2} = cs_client:find_or_create_customer_by_email(PartyRef, Normalized, Client), + ?assertEqual(Customer1#customer_Customer.id, Customer2#customer_Customer.id), + ?assertEqual(Normalized, Customer1#customer_Customer.email), + ?assertEqual(1, count_customers_by_email(Normalized)), + ok. + +find_or_create_by_email_invalid_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-invalid">>}, + lists:foreach( + fun(Email) -> + {exception, #base_InvalidRequest{}} = + cs_client:find_or_create_customer_by_email(PartyRef, Email, Client) + end, + [ + <<"">>, + <<" ">>, + <<"no-at-sign">>, + %% A NUL byte cannot be stored in a text column at all + <<"nul@byte.test", 0>>, + %% Past the envelope limit the value no longer fits into the unique index + <<(binary:copy(<<"x">>, 400))/binary, "@long.test">> + ] + ), + ok. + +create_customer_email_conflict_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-conflict">>}, + Email = <<"conflict@affinity.test">>, + Params = #customer_CustomerParams{party_ref = PartyRef, email = Email}, + {ok, Customer} = cs_client:create_customer(Params, Client), + {exception, #customer_CustomerEmailConflict{id = ConflictID}} = cs_client:create_customer(Params, Client), + ?assertEqual(Customer#customer_Customer.id, ConflictID), + ok. + +%% Email uniqueness is scoped to the party +create_customer_email_other_party_test(Config) -> + Client = ?config(client, Config), + Email = <<"shared@affinity.test">>, + {ok, Customer1} = cs_client:create_customer( + #customer_CustomerParams{party_ref = #domain_PartyConfigRef{id = <<"party-email-shared-1">>}, email = Email}, + Client + ), + {ok, Customer2} = cs_client:create_customer( + #customer_CustomerParams{party_ref = #domain_PartyConfigRef{id = <<"party-email-shared-2">>}, email = Email}, + Client + ), + ?assertNotEqual(Customer1#customer_Customer.id, Customer2#customer_Customer.id), + ?assertEqual(2, count_customers_by_email(Email)), + ok. + +get_by_email_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-get">>}, + Email = <<"get@affinity.test">>, + {ok, Customer} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + {ok, State} = cs_client:get_customer_by_email(PartyRef, Email, Client), + StoredCustomer = State#customer_CustomerState.customer, + ?assertEqual(Customer#customer_Customer.id, StoredCustomer#customer_Customer.id), + ?assertEqual(Email, StoredCustomer#customer_Customer.email), + ok. + +get_by_email_not_found_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-get-missing">>}, + {exception, #customer_CustomerNotFound{}} = + cs_client:get_customer_by_email(PartyRef, <<"missing@affinity.test">>, Client), + ok. + +%% Terminal Affinity Tests + +terminal_affinities_empty_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-empty">>, Client), + ?assertEqual({ok, []}, cs_client:get_terminal_affinities(CustomerID, Client)), + ok. + +terminal_affinities_customer_not_found_test(Config) -> + Client = ?config(client, Config), + FakeId = <<"00000000-0000-0000-0000-000000000000">>, + {exception, #customer_CustomerNotFound{}} = cs_client:get_terminal_affinities(FakeId, Client), + ok. + +%% Affinities are listed in bind order, earliest first +bind_terminal_affinity_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-bind">>, Client), + {ok, First} = bind_affinity(CustomerID, 1, 10, undefined, Client), + {ok, Second} = bind_affinity(CustomerID, 1, 11, undefined, Client), + ?assert(First#customer_TerminalAffinity.bind_seq < Second#customer_TerminalAffinity.bind_seq), + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual([{1, 10}, {1, 11}], [affinity_key(A) || A <- Affinities]), + ok. + +%% Rebinding the same terminal keeps bind_seq and only moves last_used_at forward +bind_terminal_affinity_idempotent_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-idempotent">>, Client), + {ok, First} = bind_affinity(CustomerID, 1, 12, undefined, Client), + ok = timer:sleep(50), + {ok, Second} = bind_affinity(CustomerID, 1, 12, undefined, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), + %% Two binds are two transactions, so NOW() strictly advances between them + ?assert(last_used_at(Second) > last_used_at(First)), + ?assertEqual(First#customer_TerminalAffinity.bound_at, Second#customer_TerminalAffinity.bound_at), + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(1, length(Affinities)), + ?assertEqual([], released_affinities(CustomerID)), + ok. + +%% Hard TTL: an affinity bound long ago expires and is rebound at the tail +bind_terminal_affinity_ttl_since_bound_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-bound">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 20, undefined, Client), + 1 = backdate_affinities(CustomerID, ?TEN_DAYS, ?TEN_DAYS), + {ok, Second} = bind_affinity(CustomerID, 2, 20, {since_bound, 86400}, Client), + ?assert(Second#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(Second#customer_TerminalAffinity.bind_seq, Live#customer_TerminalAffinity.bind_seq), + ok. + +%% Sliding TTL looks at last_used_at, hard TTL at bound_at — same row, different verdicts +bind_terminal_affinity_ttl_since_last_use_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-last-use">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 21, undefined, Client), + 1 = backdate_affinities(CustomerID, ?TEN_DAYS, 0), + {ok, Second} = bind_affinity(CustomerID, 2, 21, {since_last_use, 86400}, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), + ?assertEqual([], released_affinities(CustomerID)), + %% bound_at is still stale, so the hard TTL does expire the very same row + {ok, Third} = bind_affinity(CustomerID, 2, 21, {since_bound, 86400}, Client), + ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + ok. + +%% The sliding TTL in its expiring position: idle for longer than the term, rebound at the tail +bind_terminal_affinity_ttl_since_last_use_expires_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-last-use-exp">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 23, undefined, Client), + %% bound_at stays fresh, so only the sliding base is stale + 1 = backdate_affinities(CustomerID, 0, ?TEN_DAYS), + {ok, Second} = bind_affinity(CustomerID, 2, 23, {since_last_use, 86400}, Client), + ?assert(Second#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + ok. + +%% A deadline is parsed before it reaches the database: neither garbage nor the special +%% values PostgreSQL would accept ('now', 'infinity', ...) may reach the cutoff +bind_terminal_affinity_invalid_ttl_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-invalid-ttl">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 25, undefined, Client), + lists:foreach( + fun(Deadline) -> + {exception, #base_InvalidRequest{}} = + bind_affinity(CustomerID, 2, 25, {deadline, Deadline}, Client) + end, + %% The last one is a timestamp without an offset: its meaning would depend on + %% the session TimeZone rather than on what the caller sent + [<<"not-a-timestamp">>, <<"now">>, <<"infinity">>, <<"yesterday">>, <<"2026-01-01T00:00:00">>] + ), + {exception, #base_InvalidRequest{}} = bind_affinity(CustomerID, 2, 25, {since_bound, -5}, Client), + {exception, #base_InvalidRequest{}} = bind_affinity(CustomerID, 2, 25, {since_last_use, -5}, Client), + %% Nothing was released and nothing was rebound + ?assertEqual([], released_affinities(CustomerID)), + {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Live#customer_TerminalAffinity.bind_seq), + ok. + +%% An absolute deadline expires the affinity once passed, however fresh its bases are; +%% until then it is inert +bind_terminal_affinity_ttl_deadline_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-deadline">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 22, undefined, Client), + Future = {deadline, <<"2100-01-01T00:00:00Z">>}, + {ok, Second} = bind_affinity(CustomerID, 2, 22, Future, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), + ?assertEqual([], released_affinities(CustomerID)), + %% Nothing is backdated: the row is seconds old and still gets expired + Past = {deadline, <<"2000-01-01T00:00:00Z">>}, + {ok, Third} = bind_affinity(CustomerID, 2, 22, Past, Client), + ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + ok. + +release_terminal_affinity_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-release">>, Client), + {ok, _} = bind_affinity(CustomerID, 3, 30, undefined, Client), + {ok, _} = bind_affinity(CustomerID, 3, 31, undefined, Client), + Params = #customer_ReleaseTerminalAffinityParams{ + customer_id = CustomerID, + key = terminal_key(3, 30), + reason = <<"manual">> + }, + {ok, ok} = cs_client:release_terminal_affinity(Params, Client), + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual([{3, 31}], [affinity_key(A) || A <- Affinities]), + %% Releasing an affinity that is already gone is a no-op, not an error + {ok, ok} = cs_client:release_terminal_affinity(Params, Client), + %% A missing customer is still reported as such + {exception, #customer_CustomerNotFound{}} = cs_client:release_terminal_affinity( + Params#customer_ReleaseTerminalAffinityParams{customer_id = <<"00000000-0000-0000-0000-000000000000">>}, + Client + ), + ok. + +%% Bulk release covers every customer bound to the terminal and nothing else +release_terminal_affinities_by_terminal_test(Config) -> + Client = ?config(client, Config), + CustomerID1 = create_affinity_customer(<<"party-affinity-release-terminal-1">>, Client), + CustomerID2 = create_affinity_customer(<<"party-affinity-release-terminal-2">>, Client), + lists:foreach( + fun(CustomerID) -> + {ok, _} = bind_affinity(CustomerID, 4, 40, undefined, Client), + {ok, _} = bind_affinity(CustomerID, 4, 41, undefined, Client) + end, + [CustomerID1, CustomerID2] + ), + {ok, ok} = cs_client:release_terminal_affinities_by_terminal( + terminal_key(4, 40), <<"terminal_disabled">>, Client + ), + lists:foreach( + fun(CustomerID) -> + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual([{4, 41}], [affinity_key(A) || A <- Affinities]) + end, + [CustomerID1, CustomerID2] + ), + ok. + +%% Soft-deleting a customer releases its affinities and frees the email +delete_customer_releases_affinities_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-affinity-delete">>}, + Email = <<"delete@affinity.test">>, + {ok, Customer} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + CustomerID = Customer#customer_Customer.id, + {ok, Affinity} = bind_affinity(CustomerID, 5, 50, undefined, Client), + {ok, ok} = cs_client:delete_customer(CustomerID, Client), + ?assertEqual( + [{Affinity#customer_TerminalAffinity.bind_seq, <<"customer_deleted">>}], + released_affinities(CustomerID) + ), + {ok, Recreated} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + RecreatedID = Recreated#customer_Customer.id, + ?assertNotEqual(CustomerID, RecreatedID), + ?assertEqual({ok, []}, cs_client:get_terminal_affinities(RecreatedID, Client)), + ok. + %% Bank Card Storage Tests create_bank_card_test(Config) -> @@ -479,3 +827,77 @@ invalidate_recurrent_token_test(Config) -> {ok, Tokens} = cs_client:get_recurrent_tokens(BankCardId, Client), ?assertEqual(0, length(Tokens)), ok. + +%% Internal functions + +create_affinity_customer(PartyID, Client) -> + {ok, Customer} = cs_client:create_customer( + #customer_CustomerParams{party_ref = #domain_PartyConfigRef{id = PartyID}}, + Client + ), + Customer#customer_Customer.id. + +bind_affinity(CustomerID, ProviderID, TerminalID, Ttl, Client) -> + cs_client:bind_terminal_affinity( + #customer_TerminalAffinityParams{ + customer_id = CustomerID, + provider_ref = #domain_ProviderRef{id = ProviderID}, + terminal_ref = #domain_TerminalRef{id = TerminalID}, + ttl = Ttl + }, + Client + ). + +terminal_key(ProviderID, TerminalID) -> + #customer_ProviderTerminalKey{ + provider_ref = #domain_ProviderRef{id = ProviderID}, + terminal_ref = #domain_TerminalRef{id = TerminalID} + }. + +affinity_key(#customer_TerminalAffinity{provider_ref = ProviderRef, terminal_ref = TerminalRef}) -> + {ProviderRef#domain_ProviderRef.id, TerminalRef#domain_TerminalRef.id}. + +last_used_at(#customer_TerminalAffinity{last_used_at = Timestamp}) -> + calendar:rfc3339_to_system_time(binary_to_list(Timestamp), [{unit, microsecond}]). + +%% Direct database access, to observe what the API deliberately does not expose + +count_customers_by_email(Email) -> + Query = + """ + SELECT count(*) + FROM customer + WHERE email = $1 + AND deleted_at IS NULL + """, + [{Count}] = select(Query, [Email]), + Count. + +released_affinities(CustomerID) -> + Query = + """ + SELECT bind_seq, released_reason + FROM terminal_affinity + WHERE customer_id = $1::uuid + AND released_at IS NOT NULL + ORDER BY bind_seq + """, + select(Query, [CustomerID]). + +backdate_affinities(CustomerID, BoundAge, LastUsedAge) -> + Query = + """ + UPDATE terminal_affinity + SET bound_at = NOW() - ($2::int * interval '1 second'), + last_used_at = NOW() - ($3::int * interval '1 second') + WHERE customer_id = $1::uuid + AND released_at IS NULL + """, + {ok, Count} = epg_pool:query(default_pool, Query, [CustomerID, BoundAge, LastUsedAge]), + Count. + +select(Query, Params) -> + case epg_pool:query(default_pool, Query, Params) of + {ok, _Columns, Rows} -> Rows; + {ok, _Count, _Columns, Rows} -> Rows + end. diff --git a/rebar.config b/rebar.config index 1df5189..854f2ee 100644 --- a/rebar.config +++ b/rebar.config @@ -25,7 +25,7 @@ {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {erl_health, {git, "https://github.com/valitydev/erlang-health.git", {branch, "master"}}}, {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.1"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.32"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "XYZ-447/routing_affinity"}}}, %% PostgreSQL connector (includes epgsql, epgsql_pool, herd) {epg_connector, {git, "https://github.com/valitydev/epg_connector.git", {tag, "v0.0.5"}}}, diff --git a/rebar.lock b/rebar.lock index bc4c88a..e8ac736 100644 --- a/rebar.lock +++ b/rebar.lock @@ -21,7 +21,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"31495ce9d95c5d1b627b349c01d9937a5ef0231c"}}, + {ref,"3bdfd4cd0022611dbf3477efdabfedf16d5e2786"}}, 0}, {<<"epg_connector">>, {git,"https://github.com/valitydev/epg_connector.git", From 7ac9ba84d5d6e3ed84b75305702634332e84290b Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Wed, 9 Sep 2026 03:58:59 +0500 Subject: [PATCH 2/5] Bump damsel --- rebar.config | 2 +- rebar.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rebar.config b/rebar.config index 854f2ee..aae8918 100644 --- a/rebar.config +++ b/rebar.config @@ -25,7 +25,7 @@ {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {erl_health, {git, "https://github.com/valitydev/erlang-health.git", {branch, "master"}}}, {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.1"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "XYZ-447/routing_affinity"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.45"}}}, %% PostgreSQL connector (includes epgsql, epgsql_pool, herd) {epg_connector, {git, "https://github.com/valitydev/epg_connector.git", {tag, "v0.0.5"}}}, diff --git a/rebar.lock b/rebar.lock index e8ac736..1b569c2 100644 --- a/rebar.lock +++ b/rebar.lock @@ -21,7 +21,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"3bdfd4cd0022611dbf3477efdabfedf16d5e2786"}}, + {ref,"6e280411bb43319f2fde72332d00f6f9a4c653ed"}}, 0}, {<<"epg_connector">>, {git,"https://github.com/valitydev/epg_connector.git", From 6787f1793c7457334b823dbc32927c1b57bb36dd Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 11 Sep 2026 04:18:10 +0500 Subject: [PATCH 3/5] Payment now knows what binding it created --- .../0000000000004-affinity-payment.sql | 9 + apps/cs/src/cs_customer_handler.erl | 7 +- apps/cs/src/cs_terminal_affinity.erl | 65 +++++-- apps/cs/src/cs_terminal_affinity_database.erl | 136 +++++++++++-- apps/cs/test/cs_integration_SUITE.erl | 184 +++++++++++++++++- rebar.config | 2 +- rebar.lock | 2 +- 7 files changed, 369 insertions(+), 36 deletions(-) create mode 100644 apps/cs/priv/migrations/0000000000004-affinity-payment.sql diff --git a/apps/cs/priv/migrations/0000000000004-affinity-payment.sql b/apps/cs/priv/migrations/0000000000004-affinity-payment.sql new file mode 100644 index 0000000..9b0d8fc --- /dev/null +++ b/apps/cs/priv/migrations/0000000000004-affinity-payment.sql @@ -0,0 +1,9 @@ +-- payment_ref is already keyed by the payment (idx_payment_ref_invoice), so it is the +-- ledger of payments a Customer has been through. This column records which binding a +-- payment produced, turning that ledger into the idempotency journal of +-- BindTerminalAffinity: a bind whose payment is already in the ledger returns the +-- binding that payment made and touches nothing. +-- Nullable and without a default: rows written before this migration, and rows written +-- by AddPayment, are payments that produced no binding. +ALTER TABLE payment_ref + ADD COLUMN IF NOT EXISTS terminal_affinity_id UUID REFERENCES terminal_affinity(id); diff --git a/apps/cs/src/cs_customer_handler.erl b/apps/cs/src/cs_customer_handler.erl index 1842807..4df5ce1 100644 --- a/apps/cs/src/cs_customer_handler.erl +++ b/apps/cs/src/cs_customer_handler.erl @@ -202,15 +202,18 @@ do_handle_function('BindTerminalAffinity', {Params}, _Context, _Options) -> customer_id = CustomerID, provider_ref = ProviderRef, terminal_ref = TerminalRef, - ttl = Ttl + ttl = Ttl, + payment = Payment } = Params, - case cs_terminal_affinity:bind(CustomerID, ProviderRef, TerminalRef, Ttl) of + case cs_terminal_affinity:bind(CustomerID, ProviderRef, TerminalRef, Ttl, Payment) of {ok, Affinity} -> {ok, cs_mapper:terminal_affinity_to_thrift(Affinity)}; {error, not_found} -> woody_error:raise(business, #customer_CustomerNotFound{}); {error, invalid_request} -> woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid ttl">>]}); + {error, invalid_payment} -> + woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid payment">>]}); {error, Reason} -> woody_error:raise(system, {internal, Reason}) end; diff --git a/apps/cs/src/cs_terminal_affinity.erl b/apps/cs/src/cs_terminal_affinity.erl index b8fae8d..4592cff 100644 --- a/apps/cs/src/cs_terminal_affinity.erl +++ b/apps/cs/src/cs_terminal_affinity.erl @@ -4,7 +4,7 @@ -export([ list/1, - bind/4, + bind/5, release/3, release_by_customer/2, release_by_terminal/2 @@ -18,7 +18,8 @@ terminal_ref/0, provider_terminal_key/0, reason/0, - ttl/0 + ttl/0, + payment_ref/0 ]). %% Types @@ -28,6 +29,7 @@ -type provider_terminal_key() :: dmsl_customer_thrift:'ProviderTerminalKey'(). -type ttl() :: dmsl_domain_thrift:'RoutingAffinityTtl'() | undefined. -type reason() :: binary() | undefined. +-type payment_ref() :: dmsl_customer_thrift:'PaymentRef'(). -type affinity() :: cs_terminal_affinity_database:affinity(). %% Expiration base and the timestamp an affinity is expired when its base precedes @@ -44,20 +46,14 @@ list(CustomerID) -> Error end. --spec bind(customer_id(), provider_ref(), terminal_ref(), ttl()) -> - {ok, affinity()} | {error, not_found | invalid_request | term()}. -bind(CustomerID, ProviderRef, TerminalRef, Ttl) -> - case ttl_to_cutoff(Ttl) of - {ok, Cutoff} -> - case cs_customer:get(CustomerID) of - {ok, _Customer} -> - {ProviderRefJson, TerminalRefJson} = refs_to_json(ProviderRef, TerminalRef), - cs_terminal_affinity_database:bind( - CustomerID, ProviderRefJson, TerminalRefJson, Cutoff - ); - Error -> - Error - end; +%% The payment is both the idempotency key of the bind and the payment remembered for +%% the Customer; the database layer does the two in one transaction. +-spec bind(customer_id(), provider_ref(), terminal_ref(), ttl(), payment_ref()) -> + {ok, affinity()} | {error, not_found | invalid_request | invalid_payment | term()}. +bind(CustomerID, ProviderRef, TerminalRef, Ttl, Payment) -> + case payment_to_map(Payment) of + {ok, PaymentMap} -> + do_bind(CustomerID, ProviderRef, TerminalRef, Ttl, PaymentMap); {error, _} = Error -> Error end. @@ -98,6 +94,35 @@ release_by_terminal(#customer_ProviderTerminalKey{} = Key, Reason) -> %% Internal functions +-spec do_bind( + customer_id(), provider_ref(), terminal_ref(), ttl(), cs_terminal_affinity_database:payment_ref() +) -> + {ok, affinity()} | {error, not_found | invalid_request | term()}. +do_bind(CustomerID, ProviderRef, TerminalRef, Ttl, PaymentMap) -> + case ttl_to_cutoff(Ttl) of + {ok, Cutoff} -> + %% The bind transaction checks that the Customer exists itself: a check from + %% here, outside it, drifts apart from the write under a concurrent delete + {ProviderRefJson, TerminalRefJson} = refs_to_json(ProviderRef, TerminalRef), + cs_terminal_affinity_database:bind( + CustomerID, ProviderRefJson, TerminalRefJson, Cutoff, PaymentMap + ); + {error, _} = Error -> + Error + end. + +%% Both halves are required by the schema, but a caller that skips strict validation can +%% still send an empty or absent one; NULL invoice_id would silently defeat the +%% idempotency check (NULL = NULL is never true), so it is rejected here instead. +-spec payment_to_map(payment_ref()) -> + {ok, cs_terminal_affinity_database:payment_ref()} | {error, invalid_payment}. +payment_to_map(#customer_PaymentRef{invoice_id = InvoiceID, payment_id = PaymentID}) when + is_binary(InvoiceID), InvoiceID =/= <<>>, is_binary(PaymentID), PaymentID =/= <<>> +-> + {ok, #{invoice_id => InvoiceID, payment_id => PaymentID}}; +payment_to_map(_Payment) -> + {error, invalid_payment}. + %% Every ttl variant names the moment an affinity expires; here it is turned into %% what the database layer compares: a cutoff for a base column, or a verdict that %% the active affinity is already expired regardless of its bases. @@ -107,8 +132,12 @@ ttl_to_cutoff(undefined) -> ttl_to_cutoff({Base, Timeout}) when (Base =:= since_bound orelse Base =:= since_last_use), is_integer(Timeout), Timeout >= 0 -> - Cutoff = erlang:system_time(second) - Timeout, - {ok, {Base, list_to_binary(calendar:system_time_to_rfc3339(Cutoff, [{offset, "Z"}]))}}; + %% Microseconds rather than seconds: hellgate decides whether a binding is live in + %% milliseconds, and rounding the cutoff to a second would extend its life on this + %% side of the call. + Cutoff = erlang:system_time(microsecond) - Timeout * 1000000, + Formatted = calendar:system_time_to_rfc3339(Cutoff, [{unit, microsecond}, {offset, "Z"}]), + {ok, {Base, list_to_binary(Formatted)}}; ttl_to_cutoff({deadline, Deadline}) when is_binary(Deadline) -> %% Parsed here rather than left to the PostgreSQL timestamptz parser: the latter %% turns a malformed deadline into a transaction error (a system error to the diff --git a/apps/cs/src/cs_terminal_affinity_database.erl b/apps/cs/src/cs_terminal_affinity_database.erl index a8b382d..6d1e93d 100644 --- a/apps/cs/src/cs_terminal_affinity_database.erl +++ b/apps/cs/src/cs_terminal_affinity_database.erl @@ -2,7 +2,7 @@ -export([ list/1, - bind/4, + bind/5, release/4, release_by_customer/2, release_by_terminal/3 @@ -19,12 +19,18 @@ bound_at := term(), last_used_at := term() }. -%% What bind/4 releases before writing: nothing, the active affinity whatever its +%% What bind/5 releases before writing: nothing, the active affinity whatever its %% bases (an absolute deadline has passed), or the one whose base column precedes %% the cutoff timestamp. -type cutoff() :: undefined | expired | {since_bound | since_last_use, binary()}. +%% The payment a bind is made on behalf of: the idempotency key of bind/5 and, at the +%% same time, the payment remembered for the Customer. +-type payment_ref() :: #{ + invoice_id := binary(), + payment_id := binary() +}. --export_type([customer_id/0, affinity/0, cutoff/0]). +-export_type([customer_id/0, affinity/0, cutoff/0, payment_ref/0]). %% API @@ -42,13 +48,26 @@ list(CustomerID) -> {error, Reason} -> {error, Reason} end. --spec bind(customer_id(), binary(), binary(), cutoff()) -> {ok, affinity()} | {error, term()}. -bind(CustomerID, ProviderRef, TerminalRef, Cutoff) -> - %% Two statements in a single transaction rather than a data-modifying CTE: CTE +-spec bind(customer_id(), binary(), binary(), cutoff(), payment_ref()) -> + {ok, affinity()} | {error, term()}. +bind(CustomerID, ProviderRef, TerminalRef, Cutoff, Payment) -> + %% Several statements in a single transaction rather than a data-modifying CTE: CTE %% branches share one snapshot and would not see each other's effects. Fun = fun(Conn) -> - ok = release_expired(Conn, CustomerID, ProviderRef, TerminalRef, Cutoff), - upsert(Conn, CustomerID, ProviderRef, TerminalRef) + ok = lock_customer(Conn, CustomerID), + %% Idempotency by payment: the payment ledger remembers which binding each + %% payment made, and a repeat call by the same payment returns it untouched. + %% Otherwise a retried machine step would read as another successful payment: + %% a binding expired in between would be released and rebound at the tail. + case remember_payment(Conn, CustomerID, Payment) of + {bound, AffinityID} -> + get_affinity(Conn, AffinityID); + {fresh, PaymentRefID} -> + ok = release_expired(Conn, CustomerID, ProviderRef, TerminalRef, Cutoff), + {Affinity, AffinityID} = upsert(Conn, CustomerID, ProviderRef, TerminalRef), + ok = link_payment(Conn, PaymentRefID, AffinityID), + {ok, Affinity} + end end, case epg_pool:transaction(?POOL, Fun) of {ok, _Affinity} = Result -> Result; @@ -105,6 +124,93 @@ release_by_terminal(ProviderRef, TerminalRef, Reason) -> %% Internal functions +%% The deletion check lives inside the transaction and takes a shared row lock: done +%% outside it, before the transaction, a concurrent Delete fits between the check and +%% the write, and the binding would end up on a deleted Customer. +lock_customer(Conn, CustomerID) -> + Query = """ + SELECT 1 + FROM customer + WHERE id = $1::uuid + AND deleted_at IS NULL + FOR SHARE + """, + case query_rows(Conn, Query, [CustomerID]) of + {ok, [_]} -> ok; + {ok, []} -> rollback(not_found); + {error, Reason} -> rollback(Reason) + end. + +%% Puts the payment in the ledger and answers whether it has already made a binding. +%% Writing to the ledger is exactly what AddPayment does, so hellgate no longer calls +%% AddPayment separately. +remember_payment(Conn, CustomerID, #{invoice_id := InvoiceID, payment_id := PaymentID}) -> + Query = """ + INSERT INTO payment_ref (customer_id, invoice_id, payment_id) + VALUES ($1::uuid, $2, $3) + ON CONFLICT (invoice_id, payment_id) DO NOTHING + RETURNING id + """, + case query_rows(Conn, Query, [CustomerID, InvoiceID, PaymentID]) of + {ok, [{PaymentRefID}]} -> {fresh, PaymentRefID}; + {ok, []} -> lookup_payment(Conn, CustomerID, InvoiceID, PaymentID); + {error, Reason} -> rollback(Reason) + end. + +lookup_payment(Conn, CustomerID, InvoiceID, PaymentID) -> + Query = """ + SELECT id, customer_id, terminal_affinity_id + FROM payment_ref + WHERE invoice_id = $1 + AND payment_id = $2 + """, + case query_rows(Conn, Query, [InvoiceID, PaymentID]) of + %% The ledger is unique by payment database-wide, so the row found may belong to + %% someone else. It does not become ours: linking it to our binding would credit + %% one payer's payment to another payer's binding. + {ok, [{_PaymentRefID, Owner, _AffinityID}]} when Owner =/= CustomerID -> + rollback(payment_of_other_customer); + %% The payment is in the ledger but no binding is credited to it: either AddPayment + %% wrote it, or it predates this migration. It has made no binding yet, so we do. + {ok, [{PaymentRefID, _Owner, null}]} -> + {fresh, PaymentRefID}; + {ok, [{_PaymentRefID, _Owner, AffinityID}]} -> + {bound, AffinityID}; + %% The insert lost to a conflict, yet the row is nowhere to be seen: a neighbouring + %% transaction is writing it and has not committed. It will finish its own binding, + %% so there is nothing to redo — we fail, and the caller retries the step later. + {ok, []} -> + rollback(concurrent_bind); + {error, Reason} -> + rollback(Reason) + end. + +%% The binding this payment made last time. Released ones are not filtered out: the +%% payment has already done its work, and what became of the binding afterwards — an +%% operator released it, or it expired — is no reason for a retry alone to make a second. +get_affinity(Conn, AffinityID) -> + Query = """ + SELECT provider_ref, terminal_ref, bind_seq, bound_at, last_used_at + FROM terminal_affinity + WHERE id = $1::uuid + """, + case query_rows(Conn, Query, [AffinityID]) of + {ok, [Row]} -> {ok, row_to_affinity(Row)}; + {ok, []} -> rollback(affinity_not_found); + {error, Reason} -> rollback(Reason) + end. + +link_payment(Conn, PaymentRefID, AffinityID) -> + Query = """ + UPDATE payment_ref + SET terminal_affinity_id = $2::uuid + WHERE id = $1::uuid + """, + case epg_pool:query(Conn, Query, [PaymentRefID, AffinityID]) of + {ok, _} -> ok; + {error, Reason} -> rollback(Reason) + end. + release_expired(_Conn, _CustomerID, _ProviderRef, _TerminalRef, undefined) -> ok; release_expired(Conn, CustomerID, ProviderRef, TerminalRef, expired) -> @@ -129,7 +235,7 @@ release_expired(Conn, CustomerID, ProviderRef, TerminalRef, {Base, CutoffAt}) -> AND terminal_ref = $3 AND released_at IS NULL AND (CASE $5::text WHEN 'since_bound' THEN bound_at ELSE last_used_at END) - < $4::text::timestamptz + <= $4::text::timestamptz """, Params = [CustomerID, ProviderRef, TerminalRef, CutoffAt, atom_to_binary(Base, utf8)], case epg_pool:query(Conn, Query, Params) of @@ -144,12 +250,16 @@ upsert(Conn, CustomerID, ProviderRef, TerminalRef) -> VALUES ($1::uuid, $2, $3) ON CONFLICT (customer_id, provider_ref, terminal_ref) WHERE released_at IS NULL DO UPDATE SET last_used_at = NOW() - RETURNING provider_ref, terminal_ref, bind_seq, bound_at, last_used_at + RETURNING id, provider_ref, terminal_ref, bind_seq, bound_at, last_used_at """, case query_rows(Conn, Query, [CustomerID, ProviderRef, TerminalRef]) of - {ok, [Row]} -> {ok, row_to_affinity(Row)}; - {ok, []} -> rollback(failed_to_bind); - {error, Reason} -> rollback(Reason) + {ok, [{AffinityID, ProviderRefOut, TerminalRefOut, BindSeq, BoundAt, LastUsedAt}]} -> + Row = {ProviderRefOut, TerminalRefOut, BindSeq, BoundAt, LastUsedAt}, + {row_to_affinity(Row), AffinityID}; + {ok, []} -> + rollback(failed_to_bind); + {error, Reason} -> + rollback(Reason) end. %% Only a raised exception rolls the transaction back; a plain {error, _} return diff --git a/apps/cs/test/cs_integration_SUITE.erl b/apps/cs/test/cs_integration_SUITE.erl index 99fbf8e..1ba8c1e 100644 --- a/apps/cs/test/cs_integration_SUITE.erl +++ b/apps/cs/test/cs_integration_SUITE.erl @@ -55,6 +55,13 @@ terminal_affinities_customer_not_found_test/1, bind_terminal_affinity_test/1, bind_terminal_affinity_idempotent_test/1, + bind_terminal_affinity_same_payment_test/1, + bind_terminal_affinity_expired_same_payment_test/1, + bind_terminal_affinity_expired_new_payment_test/1, + bind_terminal_affinity_records_payment_test/1, + bind_terminal_affinity_stale_payment_test/1, + bind_terminal_affinity_released_payment_test/1, + bind_terminal_affinity_after_add_payment_test/1, bind_terminal_affinity_ttl_since_bound_test/1, bind_terminal_affinity_ttl_since_last_use_test/1, bind_terminal_affinity_ttl_since_last_use_expires_test/1, @@ -106,6 +113,13 @@ groups() -> terminal_affinities_customer_not_found_test, bind_terminal_affinity_test, bind_terminal_affinity_idempotent_test, + bind_terminal_affinity_same_payment_test, + bind_terminal_affinity_expired_same_payment_test, + bind_terminal_affinity_expired_new_payment_test, + bind_terminal_affinity_records_payment_test, + bind_terminal_affinity_stale_payment_test, + bind_terminal_affinity_released_payment_test, + bind_terminal_affinity_after_add_payment_test, bind_terminal_affinity_ttl_since_bound_test, bind_terminal_affinity_ttl_since_last_use_test, bind_terminal_affinity_ttl_since_last_use_expires_test, @@ -598,6 +612,135 @@ bind_terminal_affinity_idempotent_test(Config) -> ?assertEqual([], released_affinities(CustomerID)), ok. +%% The payment is the idempotency key. Rebinding by the same payment changes nothing at +%% all; rebinding by a new one is a new successful payment and moves last_used_at. +bind_terminal_affinity_same_payment_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-same-payment">>, Client), + Payment = payment_ref(CustomerID), + {ok, First} = bind_affinity(CustomerID, 6, 60, undefined, Payment, Client), + ok = timer:sleep(50), + {ok, Repeat} = bind_affinity(CustomerID, 6, 60, undefined, Payment, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Repeat#customer_TerminalAffinity.bind_seq), + ?assertEqual(First#customer_TerminalAffinity.bound_at, Repeat#customer_TerminalAffinity.bound_at), + %% Not merely "did not go backwards": the retry left the row untouched + ?assertEqual(last_used_at(First), last_used_at(Repeat)), + ?assertEqual([], released_affinities(CustomerID)), + ok = timer:sleep(50), + %% A different payment on the same terminal: same binding, refreshed + {ok, Next} = bind_affinity(CustomerID, 6, 60, undefined, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Next#customer_TerminalAffinity.bind_seq), + ?assert(last_used_at(Next) > last_used_at(First)), + ?assertEqual([], released_affinities(CustomerID)), + ok. + +%% The case the payment field exists for: a binding that expired between two attempts of +%% the same machine step must not be released and rebound at the tail on the retry — +%% that would move the payer to the end of their own history because of a retry alone +bind_terminal_affinity_expired_same_payment_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-expired-same-payment">>, Client), + Payment = payment_ref(CustomerID), + {ok, First} = bind_affinity(CustomerID, 6, 61, {since_bound, 86400}, Payment, Client), + 1 = backdate_affinities(CustomerID, ?TEN_DAYS, ?TEN_DAYS), + LastUsedAt = affinity_last_used_at(CustomerID), + {ok, Repeat} = bind_affinity(CustomerID, 6, 61, {since_bound, 86400}, Payment, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Repeat#customer_TerminalAffinity.bind_seq), + ?assertEqual([], released_affinities(CustomerID)), + %% The backdated row is still the live one, still backdated + {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Live#customer_TerminalAffinity.bind_seq), + ?assertEqual(LastUsedAt, affinity_last_used_at(CustomerID)), + ok. + +%% Same expired binding, but a genuinely new payment: this is the rebind the TTL is for +bind_terminal_affinity_expired_new_payment_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-expired-new-payment">>, Client), + {ok, First} = bind_affinity(CustomerID, 6, 62, undefined, Client), + 1 = backdate_affinities(CustomerID, ?TEN_DAYS, ?TEN_DAYS), + {ok, Second} = bind_affinity(CustomerID, 6, 62, {since_bound, 86400}, Client), + ?assert(Second#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(Second#customer_TerminalAffinity.bind_seq, Live#customer_TerminalAffinity.bind_seq), + ok. + +%% Binding remembers the payment for the Customer, so hellgate needs no separate +%% AddPayment call for it — none is made here +bind_terminal_affinity_records_payment_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-records-payment">>, Client), + Payment = payment_ref(CustomerID), + #customer_PaymentRef{invoice_id = InvoiceID, payment_id = PaymentID} = Payment, + ?assertEqual({ok, []}, payments_of(CustomerID, Client)), + {ok, _} = bind_affinity(CustomerID, 6, 63, undefined, Payment, Client), + ?assertEqual({ok, [{InvoiceID, PaymentID}]}, payments_of(CustomerID, Client)), + %% A retry of the same bind does not duplicate it + {ok, _} = bind_affinity(CustomerID, 6, 63, undefined, Payment, Client), + ?assertEqual({ok, [{InvoiceID, PaymentID}]}, payments_of(CustomerID, Client)), + ok. + +%% The payment ledger remembers every payment, not just the last one: an intervening +%% payment leaves the first one's trace intact, and a repeat of the first is still +%% recognised as a repeat across it +bind_terminal_affinity_stale_payment_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-stale-payment">>, Client), + First = payment_ref(CustomerID), + {ok, Bound} = bind_affinity(CustomerID, 6, 64, undefined, First, Client), + %% An intervening payment on the same binding + {ok, _} = bind_affinity(CustomerID, 6, 64, undefined, Client), + 1 = backdate_affinities(CustomerID, ?TEN_DAYS, ?TEN_DAYS), + LastUsedAt = affinity_last_used_at(CustomerID), + %% A repeat of the first payment on an expired binding: had we not recognised the + %% repeat, the TTL would have released it and rebound it at the tail of the history + {ok, Repeat} = bind_affinity(CustomerID, 6, 64, {since_bound, 86400}, First, Client), + ?assertEqual(Bound#customer_TerminalAffinity.bind_seq, Repeat#customer_TerminalAffinity.bind_seq), + ?assertEqual([], released_affinities(CustomerID)), + ?assertEqual(LastUsedAt, affinity_last_used_at(CustomerID)), + ok. + +%% A repeat of its payment does not resurrect a released binding: the payment has done +%% its work, and that is recorded in the ledger rather than in the binding itself, which +%% by then may be gone +bind_terminal_affinity_released_payment_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-released-payment">>, Client), + Payment = payment_ref(CustomerID), + {ok, Bound} = bind_affinity(CustomerID, 6, 65, undefined, Payment, Client), + ReleaseParams = #customer_ReleaseTerminalAffinityParams{ + customer_id = CustomerID, + key = terminal_key(6, 65), + reason = <<"manual">> + }, + {ok, ok} = cs_client:release_terminal_affinity(ReleaseParams, Client), + {ok, Repeat} = bind_affinity(CustomerID, 6, 65, undefined, Payment, Client), + %% The same row comes back, but that does not make it live again + ?assertEqual(Bound#customer_TerminalAffinity.bind_seq, Repeat#customer_TerminalAffinity.bind_seq), + ?assertEqual({ok, []}, cs_client:get_terminal_affinities(CustomerID, Client)), + ?assertEqual([{Bound#customer_TerminalAffinity.bind_seq, <<"manual">>}], released_affinities(CustomerID)), + ok. + +%% A payment written by a separate AddPayment has no binding credited to it: a bind by +%% it is the first one, not a repeat. Payments that entered the ledger before this +%% migration look the same +bind_terminal_affinity_after_add_payment_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-after-add-payment">>, Client), + Payment = payment_ref(CustomerID), + #customer_PaymentRef{invoice_id = InvoiceID, payment_id = PaymentID} = Payment, + {ok, ok} = cs_client:add_payment(CustomerID, InvoiceID, PaymentID, Client), + {ok, Bound} = bind_affinity(CustomerID, 6, 66, undefined, Payment, Client), + %% The ledger still holds exactly one payment + ?assertEqual({ok, [{InvoiceID, PaymentID}]}, payments_of(CustomerID, Client)), + ok = timer:sleep(50), + %% And the next call by it is a repeat + {ok, Repeat} = bind_affinity(CustomerID, 6, 66, undefined, Payment, Client), + ?assertEqual(Bound#customer_TerminalAffinity.bind_seq, Repeat#customer_TerminalAffinity.bind_seq), + ?assertEqual(last_used_at(Bound), last_used_at(Repeat)), + ok. + %% Hard TTL: an affinity bound long ago expires and is rebound at the tail bind_terminal_affinity_ttl_since_bound_test(Config) -> Client = ?config(client, Config), @@ -837,17 +980,32 @@ create_affinity_customer(PartyID, Client) -> ), Customer#customer_Customer.id. +%% Unless a test deliberately reuses one, every bind carries its own payment: that is +%% what a second successful payment looks like to the service bind_affinity(CustomerID, ProviderID, TerminalID, Ttl, Client) -> + bind_affinity(CustomerID, ProviderID, TerminalID, Ttl, payment_ref(CustomerID), Client). + +bind_affinity(CustomerID, ProviderID, TerminalID, Ttl, Payment, Client) -> cs_client:bind_terminal_affinity( #customer_TerminalAffinityParams{ customer_id = CustomerID, provider_ref = #domain_ProviderRef{id = ProviderID}, terminal_ref = #domain_TerminalRef{id = TerminalID}, - ttl = Ttl + ttl = Ttl, + payment = Payment }, Client ). +%% payment_ref(invoice_id, payment_id) is unique database-wide, and the suite runs +%% repeatedly against the same database; the customer's fresh UUID keeps runs apart +payment_ref(CustomerID) -> + Nth = integer_to_binary(erlang:unique_integer([positive, monotonic])), + #customer_PaymentRef{ + invoice_id = <<"inv-", CustomerID/binary, "-", Nth/binary>>, + payment_id = <<"pay-", CustomerID/binary, "-", Nth/binary>> + }. + terminal_key(ProviderID, TerminalID) -> #customer_ProviderTerminalKey{ provider_ref = #domain_ProviderRef{id = ProviderID}, @@ -860,6 +1018,17 @@ affinity_key(#customer_TerminalAffinity{provider_ref = ProviderRef, terminal_ref last_used_at(#customer_TerminalAffinity{last_used_at = Timestamp}) -> calendar:rfc3339_to_system_time(binary_to_list(Timestamp), [{unit, microsecond}]). +payments_of(CustomerID, Client) -> + case cs_client:get_payments(CustomerID, 100, undefined, Client) of + {ok, #customer_CustomerPaymentsResponse{payments = Payments}} -> + {ok, [ + {InvoiceID, PaymentID} + || #customer_CustomerPayment{invoice_id = InvoiceID, payment_id = PaymentID} <- Payments + ]}; + Other -> + Other + end. + %% Direct database access, to observe what the API deliberately does not expose count_customers_by_email(Email) -> @@ -884,6 +1053,19 @@ released_affinities(CustomerID) -> """, select(Query, [CustomerID]). +%% The live binding's base, read straight from the table: an untouched retry must not +%% move it, and equality is the whole assertion +affinity_last_used_at(CustomerID) -> + Query = + """ + SELECT last_used_at + FROM terminal_affinity + WHERE customer_id = $1::uuid + AND released_at IS NULL + """, + [{LastUsedAt}] = select(Query, [CustomerID]), + LastUsedAt. + backdate_affinities(CustomerID, BoundAge, LastUsedAge) -> Query = """ diff --git a/rebar.config b/rebar.config index aae8918..e242194 100644 --- a/rebar.config +++ b/rebar.config @@ -25,7 +25,7 @@ {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {erl_health, {git, "https://github.com/valitydev/erlang-health.git", {branch, "master"}}}, {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.1"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.45"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "XYZ-447/routing_affinity_fix"}}}, %% PostgreSQL connector (includes epgsql, epgsql_pool, herd) {epg_connector, {git, "https://github.com/valitydev/epg_connector.git", {tag, "v0.0.5"}}}, diff --git a/rebar.lock b/rebar.lock index 1b569c2..fad3d3d 100644 --- a/rebar.lock +++ b/rebar.lock @@ -21,7 +21,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"6e280411bb43319f2fde72332d00f6f9a4c653ed"}}, + {ref,"c80aec379cd933ecdb9be172ef344b13c1a06b02"}}, 0}, {<<"epg_connector">>, {git,"https://github.com/valitydev/epg_connector.git", From 57ecf9912f47d447a25ec7f68fba9ecfc7998447 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 11 Sep 2026 04:45:58 +0500 Subject: [PATCH 4/5] Experimental build --- .github/workflows/build-image.yml | 145 +++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index ff53b0e..35ae3a0 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -10,12 +10,149 @@ on: env: REGISTRY: ghcr.io + IMAGE: ghcr.io/${{ github.repository }} jobs: - build-push: + # Same event-based defaults as valitydev/action-deploy-docker@v2, plus a + # per-platform matrix: platforms with a native runner build there, anything + # else falls back to QEMU on ubuntu-latest. + prepare: runs-on: ubuntu-latest + outputs: + push: ${{ steps.config.outputs.push }} + matrix: ${{ steps.config.outputs.matrix }} steps: - - uses: valitydev/action-deploy-docker@v2 + - name: 🎲 Configure build + id: config + env: + RUNNERS: '{"linux/amd64":"ubuntu-latest","linux/arm64":"ubuntu-24.04-arm"}' + run: | + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + echo "[INFO] Push event: publish a multiplatform image." + push=true + platforms=linux/amd64,linux/arm64 + else + echo "[INFO] PR event: just build an image under native platform." + push=false + platforms=linux/amd64 + fi + matrix=$(jq -nc --arg p "$platforms" --argjson r "$RUNNERS" \ + '[$p | split(",")[] | {platform: ., runner: ($r[.] // "ubuntu-latest"), emulate: ($r[.] == null)}]') + echo "[INFO] push=$push matrix=$matrix" + echo "push=$push" >> "$GITHUB_OUTPUT" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.prepare.outputs.matrix) }} + runs-on: ${{ matrix.runner }} + steps: + - name: 🖨 Checkout code + uses: actions/checkout@v4 + + - name: 💉 Set up buildargs + run: | + if [[ -f ".env" ]]; then + echo 'BUILDARGS<> "$GITHUB_ENV" + grep -v '^#' .env >> "$GITHUB_ENV" + echo 'EOF' >> "$GITHUB_ENV" + else + echo "[NOTICE] No '.env' file to source buildargs from!" + fi + platform="${{ matrix.platform }}" + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + + - name: 🏗 Set up QEMU + if: ${{ matrix.emulate }} + uses: docker/setup-qemu-action@v3 + + - name: 🏗 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🔓 Log in to the Container registry + if: ${{ needs.prepare.outputs.push == 'true' }} + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: ℹ️ Extract metadata (labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + + # Pushed by digest only; tags are attached to the manifest list in `merge`. + - name: 🚀 Build (and push by digest) + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + tags: ${{ env.IMAGE }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + ${{ env.BUILDARGS }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=${{ needs.prepare.outputs.push }} + + - name: 📦 Export digest + if: ${{ needs.prepare.outputs.push == 'true' }} + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: 📦 Upload digest + if: ${{ needs.prepare.outputs.push == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: [prepare, build] + if: ${{ needs.prepare.outputs.push == 'true' }} + runs-on: ubuntu-latest + steps: + - name: 📦 Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: 🏗 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🔓 Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: ℹ️ Extract metadata (tags) for Docker + id: meta + uses: docker/metadata-action@v5 with: - registry-username: ${{ github.actor }} - registry-access-token: ${{ secrets.GITHUB_TOKEN }} + images: ${{ env.IMAGE }} + tags: | + type=sha,enable=${{ !startsWith(github.ref_name, 'epic') }} + type=raw,value=sha-{{ sha }}-{{ branch }},enable=${{ startsWith(github.ref_name, 'epic') }} + + - name: 🚀 Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: 🔍 Inspect image + run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }} From 8b254d7629a12c45e1a5efbc99b45157c761fcbc Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 11 Sep 2026 06:02:29 +0500 Subject: [PATCH 5/5] New build version --- .github/workflows/build-image.yml | 156 ++---------------------------- 1 file changed, 9 insertions(+), 147 deletions(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 35ae3a0..d54c70d 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -8,151 +8,13 @@ on: pull_request: branches: ['**'] -env: - REGISTRY: ghcr.io - IMAGE: ghcr.io/${{ github.repository }} - jobs: - # Same event-based defaults as valitydev/action-deploy-docker@v2, plus a - # per-platform matrix: platforms with a native runner build there, anything - # else falls back to QEMU on ubuntu-latest. - prepare: - runs-on: ubuntu-latest - outputs: - push: ${{ steps.config.outputs.push }} - matrix: ${{ steps.config.outputs.matrix }} - steps: - - name: 🎲 Configure build - id: config - env: - RUNNERS: '{"linux/amd64":"ubuntu-latest","linux/arm64":"ubuntu-24.04-arm"}' - run: | - if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then - echo "[INFO] Push event: publish a multiplatform image." - push=true - platforms=linux/amd64,linux/arm64 - else - echo "[INFO] PR event: just build an image under native platform." - push=false - platforms=linux/amd64 - fi - matrix=$(jq -nc --arg p "$platforms" --argjson r "$RUNNERS" \ - '[$p | split(",")[] | {platform: ., runner: ($r[.] // "ubuntu-latest"), emulate: ($r[.] == null)}]') - echo "[INFO] push=$push matrix=$matrix" - echo "push=$push" >> "$GITHUB_OUTPUT" - echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - - build: - needs: prepare - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.prepare.outputs.matrix) }} - runs-on: ${{ matrix.runner }} - steps: - - name: 🖨 Checkout code - uses: actions/checkout@v4 - - - name: 💉 Set up buildargs - run: | - if [[ -f ".env" ]]; then - echo 'BUILDARGS<> "$GITHUB_ENV" - grep -v '^#' .env >> "$GITHUB_ENV" - echo 'EOF' >> "$GITHUB_ENV" - else - echo "[NOTICE] No '.env' file to source buildargs from!" - fi - platform="${{ matrix.platform }}" - echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - - - name: 🏗 Set up QEMU - if: ${{ matrix.emulate }} - uses: docker/setup-qemu-action@v3 - - - name: 🏗 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: 🔓 Log in to the Container registry - if: ${{ needs.prepare.outputs.push == 'true' }} - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: ℹ️ Extract metadata (labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - - # Pushed by digest only; tags are attached to the manifest list in `merge`. - - name: 🚀 Build (and push by digest) - id: build - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: ${{ matrix.platform }} - tags: ${{ env.IMAGE }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - ${{ env.BUILDARGS }} - outputs: type=image,push-by-digest=true,name-canonical=true,push=${{ needs.prepare.outputs.push }} - - - name: 📦 Export digest - if: ${{ needs.prepare.outputs.push == 'true' }} - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: 📦 Upload digest - if: ${{ needs.prepare.outputs.push == 'true' }} - uses: actions/upload-artifact@v4 - with: - name: digests-${{ env.PLATFORM_PAIR }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - merge: - needs: [prepare, build] - if: ${{ needs.prepare.outputs.push == 'true' }} - runs-on: ubuntu-latest - steps: - - name: 📦 Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/digests - pattern: digests-* - merge-multiple: true - - - name: 🏗 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: 🔓 Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: ℹ️ Extract metadata (tags) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - tags: | - type=sha,enable=${{ !startsWith(github.ref_name, 'epic') }} - type=raw,value=sha-{{ sha }}-{{ branch }},enable=${{ startsWith(github.ref_name, 'epic') }} - - - name: 🚀 Create manifest list and push - working-directory: /tmp/digests - run: | - docker buildx imagetools create \ - $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.IMAGE }}@sha256:%s ' *) - - - name: 🔍 Inspect image - run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }} + build-image: + uses: valitydev/action-deploy-docker/.github/workflows/build-image.yml@v3 + permissions: + contents: read + packages: write + with: + registry-username: ${{ github.actor }} + secrets: + registry-access-token: ${{ secrets.GITHUB_TOKEN }}