From 1c5d3d202ba475586252f4addefc8654ec9dcb57 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 18 Feb 2026 22:06:51 +0100 Subject: [PATCH] quotation v2 service --- Makefile | 4 +- api/billing/documents/go.mod | 6 +- api/billing/documents/go.sum | 12 +- .../quotation_service_v2/converters.go | 38 +++-- .../quotation/quotation_service_v2/helpers.go | 23 +++ .../quotation_service_v2/service_e2e_test.go | 153 +++++++++++++++--- .../quotation_service_v2/single_processor.go | 36 +++-- .../computed_quote_enricher.go | 12 ++ .../quote_computation_service/economics.go | 40 +++++ .../intent_adapters.go | 2 +- .../quote_computation_service/plan.go | 43 ++--- .../quote_computation_service/planner.go | 24 +-- .../route_settlement.go | 2 +- .../route_spec_builder.go | 6 +- .../fingerprint_test.go | 37 ++--- .../quote_request_validator.go | 56 ++++++- .../quote_request_validator_test.go | 73 +++++++-- .../quote_response_mapper_v2/input.go | 25 +-- .../quote_response_mapper_v2/service.go | 82 ++++++++-- .../quote_response_mapper_v2/service_test.go | 77 ++++++++- .../transfer_intent_hydrator/hydrator.go | 113 +++++++++++-- .../transfer_intent_hydrator/quote_intent.go | 9 ++ .../transfer_intent_hydrator_test.go | 49 +++++- api/proto/common/payment/v1/card.proto | 2 - api/proto/common/payment/v1/settlement.proto | 4 +- .../payments/quotation/v2/interface.proto | 52 +++--- .../payments/quotation/v2/quotation.proto | 17 +- api/server/go.mod | 6 +- api/server/go.sum | 12 +- api/server/interface/api/sresponse/payment.go | 15 +- 30 files changed, 799 insertions(+), 231 deletions(-) create mode 100644 api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go diff --git a/Makefile b/Makefile index 8c5a17ab..845aaca6 100644 --- a/Makefile +++ b/Makefile @@ -160,8 +160,8 @@ generate-api: # Generate Flutter code (json_serializable, etc.) generate-frontend: @echo "$(GREEN)Generating Flutter code...$(NC)" - @cd frontend/pshared && flutter pub run build_runner build --delete-conflicting-outputs - @cd frontend/pweb && flutter pub run build_runner build --delete-conflicting-outputs + @cd frontend/pshared && dart run build_runner build --delete-conflicting-outputs + @cd frontend/pweb && dart run build_runner build --delete-conflicting-outputs @echo "$(GREEN)✅ Flutter code generation complete$(NC)" # Clean everything diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index 287f815f..e201776c 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -6,8 +6,8 @@ replace github.com/tech/sendico/pkg => ../../pkg require ( github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.8 - github.com/aws/aws-sdk-go-v2/credentials v1.19.8 + github.com/aws/aws-sdk-go-v2/config v1.32.9 + github.com/aws/aws-sdk-go-v2/credentials v1.19.9 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/jung-kurt/gofpdf v1.16.2 github.com/prometheus/client_golang v1.23.2 @@ -31,7 +31,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index d3ae4fe9..25283cb1 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -8,10 +8,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6ce github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= -github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= +github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= +github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= @@ -34,8 +34,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIM github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go index 7f8c9dee..0f986098 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go @@ -11,6 +11,7 @@ import ( paymenttypes "github.com/tech/sendico/pkg/payments/types" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "google.golang.org/protobuf/types/known/timestamppb" @@ -73,18 +74,33 @@ func canonicalFromSnapshot( PricedAt: pricedAt, } } + resolvedSettlementMode := resolvedSettlementModeFromSnapshot(snapshot) return quote_response_mapper_v2.CanonicalQuote{ - QuoteRef: firstNonEmpty(snapshot.QuoteRef, fallbackQuoteRef), - DebitAmount: protoMoneyFromModel(snapshot.DebitAmount), - CreditAmount: protoMoneyFromModel(snapshot.ExpectedSettlementAmount), - TotalCost: protoMoneyFromModel(snapshot.TotalCost), - FeeLines: feeLinesToProto(snapshot.FeeLines), - FeeRules: feeRulesToProto(snapshot.FeeRules), - Route: protoRouteFromModel(snapshot.Route), - Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions), - FXQuote: protoFXQuoteFromModel(snapshot.FXQuote), - ExpiresAt: expiresAt, - PricedAt: pricedAt, + QuoteRef: firstNonEmpty(snapshot.QuoteRef, fallbackQuoteRef), + TransferPrincipalAmount: protoMoneyFromModel(snapshot.DebitAmount), + DestinationAmount: protoMoneyFromModel(snapshot.ExpectedSettlementAmount), + PayerTotalDebitAmount: protoMoneyFromModel(snapshot.TotalCost), + FeeLines: feeLinesToProto(snapshot.FeeLines), + FeeRules: feeRulesToProto(snapshot.FeeRules), + Route: protoRouteFromModel(snapshot.Route), + Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions), + FXQuote: protoFXQuoteFromModel(snapshot.FXQuote), + ResolvedSettlementMode: resolvedSettlementMode, + ResolvedFeeTreatment: resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode), + ExpiresAt: expiresAt, + PricedAt: pricedAt, + } +} + +func resolvedSettlementModeFromSnapshot(snapshot *model.PaymentQuoteSnapshot) paymentv1.SettlementMode { + if snapshot == nil || snapshot.Route == nil || snapshot.Route.Settlement == nil { + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } + switch strings.ToUpper(strings.TrimSpace(snapshot.Route.Settlement.Model)) { + case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED": + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE } } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go index b092331d..def6a16a 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go @@ -4,7 +4,10 @@ import ( "strings" "time" + "github.com/tech/sendico/payments/storage/model" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) func minExpiry(values []time.Time) (time.Time, bool) { @@ -42,3 +45,23 @@ func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money { Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), } } + +func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.SettlementMode { + switch mode { + case model.SettlementModeFixReceived: + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + case model.SettlementModeFixSource: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + } +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go index 173a7b4a..d07bb43d 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -23,7 +23,6 @@ import ( endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -74,23 +73,23 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED { t.Fatalf("expected empty block reason, got=%s", got.String()) } - if got, want := quote.GetDebitAmount().GetAmount(), "100"; got != want { - t.Fatalf("unexpected debit amount: got=%q want=%q", got, want) + if got, want := quote.GetTransferPrincipalAmount().GetAmount(), "100"; got != want { + t.Fatalf("unexpected principal amount: got=%q want=%q", got, want) } - if got, want := quote.GetDebitAmount().GetCurrency(), "USDT"; got != want { - t.Fatalf("unexpected debit currency: got=%q want=%q", got, want) + if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected principal currency: got=%q want=%q", got, want) } - if got, want := quote.GetCreditAmount().GetAmount(), "9150"; got != want { - t.Fatalf("unexpected credit amount: got=%q want=%q", got, want) + if got, want := quote.GetDestinationAmount().GetAmount(), "9150"; got != want { + t.Fatalf("unexpected destination amount: got=%q want=%q", got, want) } - if got, want := quote.GetCreditAmount().GetCurrency(), "RUB"; got != want { - t.Fatalf("unexpected credit currency: got=%q want=%q", got, want) + if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want { + t.Fatalf("unexpected destination currency: got=%q want=%q", got, want) } - if got, want := quote.GetTotalCost().GetAmount(), "101.8"; got != want { - t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) + if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "101.8"; got != want { + t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want) } - if got, want := quote.GetTotalCost().GetCurrency(), "USDT"; got != want { - t.Fatalf("unexpected total_cost currency: got=%q want=%q", got, want) + if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected payer_total_debit_amount currency: got=%q want=%q", got, want) } if got, want := len(quote.GetFeeLines()), 2; got != want { t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want) @@ -113,6 +112,9 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want { t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want) } + if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want { + t.Fatalf("unexpected fx side: got=%s want=%s", got.String(), want.String()) + } if quote.GetRoute() == nil { t.Fatalf("expected route specification") } @@ -137,6 +139,9 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want { t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want) } + if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_source"; got != want { + t.Fatalf("unexpected route settlement model: got=%q want=%q", got, want) + } if quote.GetExecutionConditions() == nil { t.Fatalf("expected execution conditions") } @@ -146,6 +151,12 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { if quote.GetExecutionConditions().GetPrefundingRequired() { t.Fatalf("expected prefunding_required=false") } + if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected resolved_fee_treatment: got=%s want=%s", got.String(), want.String()) + } // Verify that idempotent reuse keeps full quote payload (including fee lines/rules). reused, err := svc.ProcessQuotePayment(context.Background(), req) @@ -169,6 +180,67 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { t.Logf("single response:\n%s", mustProtoJSON(t, result.Response)) } +func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + orgID := bson.NewObjectID() + + store := newInMemoryQuotesStore() + core := &fakeQuoteCore{ + now: now, + resultTTL: 10 * time.Minute, + fxTTL: 5 * time.Minute, + } + svc := New(Dependencies{ + QuotesStore: store, + Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { + return "q-intent-single" + })), + Computation: quote_computation_service.New(core), + Now: func() time.Time { return now }, + NewRef: func() string { return "quote-fx-expiry-clamp" }, + }) + + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: orgID.Hex(), + }, + IdempotencyKey: "idem-fx-expiry-clamp", + InitiatorRef: "initiator-42", + Intent: makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"), + } + + result, err := svc.ProcessQuotePayment(context.Background(), req) + if err != nil { + t.Fatalf("ProcessQuotePayment returned error: %v", err) + } + if result == nil || result.Response == nil || result.Response.GetQuote() == nil { + t.Fatalf("expected quote response") + } + quote := result.Response.GetQuote() + if quote.GetFxQuote() == nil { + t.Fatalf("expected fx quote") + } + if quote.GetExpiresAt() == nil { + t.Fatalf("expected quote expires_at") + } + + gotQuoteExpiry := quote.GetExpiresAt().AsTime().UTC() + gotFXExpiry := time.UnixMilli(quote.GetFxQuote().GetExpiresAtUnixMs()).UTC() + if gotQuoteExpiry.After(gotFXExpiry) { + t.Fatalf("expected quote expires_at <= fx expires_at, got quote=%s fx=%s", gotQuoteExpiry, gotFXExpiry) + } + if got, want := gotQuoteExpiry.UnixMilli(), gotFXExpiry.UnixMilli(); got != want { + t.Fatalf("expected quote expiry to be clamped to fx expiry, got=%d want=%d", got, want) + } + + if result.Record == nil { + t.Fatalf("expected persisted record") + } + if got, want := result.Record.ExpiresAt.UTC().UnixMilli(), gotFXExpiry.UnixMilli(); got != want { + t.Fatalf("expected persisted expires_at to match clamped expiry, got=%d want=%d", got, want) + } +} + func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { now := time.Unix(1_700_000_000, 0).UTC() orgID := bson.NewObjectID() @@ -192,7 +264,7 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { IdempotencyKey: "idem-batch-usdt-rub", InitiatorRef: "initiator-42", PreviewOnly: false, - Intents: []*transferv1.TransferIntent{ + Intents: []*quotationv2.QuoteIntent{ makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"), makeTransferIntent(t, "125", "USDT", "wallet-usdt-source", "4222222222222222", "RU"), makeTransferIntent(t, "80", "USDT", "wallet-usdt-source", "4333333333333333", "RU"), @@ -224,11 +296,11 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want { t.Fatalf("unexpected quote state for item %d: got=%s want=%s", i, got.String(), want.String()) } - if quote.GetDebitAmount().GetCurrency() != "USDT" { - t.Fatalf("unexpected debit currency for item %d: %q", i, quote.GetDebitAmount().GetCurrency()) + if quote.GetTransferPrincipalAmount().GetCurrency() != "USDT" { + t.Fatalf("unexpected principal currency for item %d: %q", i, quote.GetTransferPrincipalAmount().GetCurrency()) } - if quote.GetCreditAmount().GetCurrency() != "RUB" { - t.Fatalf("unexpected credit currency for item %d: %q", i, quote.GetCreditAmount().GetCurrency()) + if quote.GetDestinationAmount().GetCurrency() != "RUB" { + t.Fatalf("unexpected destination currency for item %d: %q", i, quote.GetDestinationAmount().GetCurrency()) } if quote.GetRoute() == nil { t.Fatalf("expected route for item %d", i) @@ -251,6 +323,9 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want { t.Fatalf("unexpected route settlement token for item %d: got=%q want=%q", i, got, want) } + if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_source"; got != want { + t.Fatalf("unexpected route settlement model for item %d: got=%q want=%q", i, got, want) + } if quote.GetExecutionConditions() == nil { t.Fatalf("expected execution conditions for item %d", i) } @@ -263,6 +338,18 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { if got, want := len(quote.GetFeeRules()), 1; got != want { t.Fatalf("unexpected fee rules count for item %d: got=%d want=%d", i, got, want) } + if quote.GetFxQuote() == nil { + t.Fatalf("expected fx quote for item %d", i) + } + if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want { + t.Fatalf("unexpected fx side for item %d: got=%s want=%s", i, got.String(), want.String()) + } + if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("unexpected resolved_settlement_mode for item %d: got=%s want=%s", i, got.String(), want.String()) + } + if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected resolved_fee_treatment for item %d: got=%s want=%s", i, got.String(), want.String()) + } } if got, want := core.quoteRequestIdempotencyKeys, []string{ @@ -415,8 +502,8 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T) if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want { t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want) } - if got, want := quote.GetTotalCost().GetAmount(), "102.4"; got != want { - t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) + if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "102.4"; got != want { + t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want) } } @@ -427,7 +514,7 @@ func makeTransferIntent( sourceWalletID string, destinationPAN string, destinationCountry string, -) *transferv1.TransferIntent { +) *quotationv2.QuoteIntent { t.Helper() walletData, err := bson.Marshal(pkgmodel.WalletPaymentData{WalletID: sourceWalletID}) @@ -446,7 +533,7 @@ func makeTransferIntent( t.Fatalf("failed to marshal card method data: %v", err) } - return &transferv1.TransferIntent{ + return "ationv2.QuoteIntent{ Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{ @@ -480,6 +567,8 @@ type fakeQuoteCore struct { now time.Time quoteRequestIdempotencyKeys []string + resultTTL time.Duration + fxTTL time.Duration } func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_service.BuildQuoteInput) (*quote_computation_service.ComputedQuote, time.Time, error) { @@ -564,18 +653,32 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi Base: "USDT", Quote: "RUB", }, - Side: fxv1.Side_BUY_BASE_SELL_QUOTE, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, Price: &moneyv1.Decimal{Value: rate.String()}, BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"}, QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: "RUB"}, - ExpiresAtUnixMs: f.now.Add(5 * time.Minute).UnixMilli(), + ExpiresAtUnixMs: f.now.Add(f.fxTTLValue()).UnixMilli(), Provider: "test-oracle", RateRef: "rate-usdt-rub", Firm: true, PricedAt: timestamppb.New(f.now), }, } - return quote, f.now.Add(5 * time.Minute), nil + return quote, f.now.Add(f.resultTTLValue()), nil +} + +func (f *fakeQuoteCore) resultTTLValue() time.Duration { + if f == nil || f.resultTTL <= 0 { + return 5 * time.Minute + } + return f.resultTTL +} + +func (f *fakeQuoteCore) fxTTLValue() time.Duration { + if f == nil || f.fxTTL <= 0 { + return f.resultTTLValue() + } + return f.fxTTL } func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go index 14f69a45..81f50adc 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -134,17 +134,25 @@ func (p *singleIntentProcessorV2) Process( } canonical := quote_response_mapper_v2.CanonicalQuote{ - QuoteRef: p.quoteRef, - DebitAmount: cloneProtoMoney(result.Quote.DebitAmount), - CreditAmount: cloneProtoMoney(result.Quote.CreditAmount), - TotalCost: cloneProtoMoney(result.Quote.TotalCost), - FeeLines: result.Quote.FeeLines, - FeeRules: result.Quote.FeeRules, - FXQuote: result.Quote.FXQuote, - Route: result.Quote.Route, - Conditions: result.Quote.ExecutionConditions, - ExpiresAt: result.ExpiresAt, - PricedAt: p.pricedAt, + QuoteRef: p.quoteRef, + TransferPrincipalAmount: cloneProtoMoney(result.Quote.DebitAmount), + DestinationAmount: cloneProtoMoney(result.Quote.CreditAmount), + PayerTotalDebitAmount: cloneProtoMoney(result.Quote.TotalCost), + FeeLines: result.Quote.FeeLines, + FeeRules: result.Quote.FeeRules, + FXQuote: result.Quote.FXQuote, + Route: result.Quote.Route, + Conditions: result.Quote.ExecutionConditions, + ResolvedSettlementMode: result.Quote.ResolvedSettlementMode, + ResolvedFeeTreatment: result.Quote.ResolvedFeeTreatment, + ExpiresAt: result.ExpiresAt, + PricedAt: p.pricedAt, + } + if canonical.ResolvedSettlementMode == 0 { + canonical.ResolvedSettlementMode = resolvedSettlementModeFromModel(planItem.Intent.SettlementMode) + } + if canonical.ResolvedFeeTreatment == 0 { + canonical.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(canonical.ResolvedSettlementMode) } mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{ @@ -157,12 +165,16 @@ func (p *singleIntentProcessorV2) Process( if mapped == nil || mapped.Quote == nil { return nil, merrors.InvalidArgument("mapped quote is required") } + expiresAt := result.ExpiresAt + if quoteExpiresAt := mapped.Quote.GetExpiresAt(); quoteExpiresAt != nil { + expiresAt = quoteExpiresAt.AsTime().UTC() + } p.collector.Add(&itemProcessDetail{ Index: in.Item.Index, Intent: planItem.Intent, Quote: result.Quote, - ExpiresAt: result.ExpiresAt, + ExpiresAt: expiresAt, Status: status, }) diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go index 176cfda0..dbc09a60 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go @@ -27,6 +27,18 @@ func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *Co if src.TotalCost == nil { src.TotalCost = deriveTotalCost(src.DebitAmount, src.FeeLines) } + if src.ResolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + src.ResolvedSettlementMode = item.ResolvedSettlementMode + } + if src.ResolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + src.ResolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } + if src.ResolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + src.ResolvedFeeTreatment = item.ResolvedFeeTreatment + } + if src.ResolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + src.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(src.ResolvedSettlementMode) + } return src } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go new file mode 100644 index 00000000..c5a1ac9d --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go @@ -0,0 +1,40 @@ +package quote_computation_service + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.SettlementMode { + switch mode { + case model.SettlementModeFixSource: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + case model.SettlementModeFixReceived: + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func resolvedSettlementModeFromRouteModelValue(value string) paymentv1.SettlementMode { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED": + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + case "FIX_SOURCE", "SETTLEMENT_FIX_SOURCE", "SETTLEMENT_MODE_FIX_SOURCE": + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go index 8b36bd0f..dcdc7267 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go @@ -103,6 +103,6 @@ func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) mode case transfer_intent_hydrator.QuoteSettlementModeFixReceived: return model.SettlementModeFixReceived default: - return model.SettlementModeUnspecified + return model.SettlementModeFixSource } } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go index 3f71c436..e052573f 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/pkg/model/account_role" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" @@ -34,15 +35,17 @@ type BuildQuoteInput struct { // ComputedQuote is a canonical quote payload for v2 processing. type ComputedQuote struct { - QuoteRef string - DebitAmount *moneyv1.Money - CreditAmount *moneyv1.Money - TotalCost *moneyv1.Money - FeeLines []*feesv1.DerivedPostingLine - FeeRules []*feesv1.AppliedRule - FXQuote *oraclev1.Quote - Route *quotationv2.RouteSpecification - ExecutionConditions *quotationv2.ExecutionConditions + QuoteRef string + DebitAmount *moneyv1.Money + CreditAmount *moneyv1.Money + TotalCost *moneyv1.Money + FeeLines []*feesv1.DerivedPostingLine + FeeRules []*feesv1.AppliedRule + FXQuote *oraclev1.Quote + Route *quotationv2.RouteSpecification + ExecutionConditions *quotationv2.ExecutionConditions + ResolvedSettlementMode paymentv1.SettlementMode + ResolvedFeeTreatment quotationv2.FeeTreatment } // QuoteComputationPlan is an orchestration plan for quote computations. @@ -58,16 +61,18 @@ type QuoteComputationPlan struct { // QuoteComputationPlanItem is one quote-computation unit. type QuoteComputationPlanItem struct { - Index int - IdempotencyKey string - IntentRef string - Intent model.PaymentIntent - QuoteInput BuildQuoteInput - Steps []*QuoteComputationStep - Funding *gateway_funding_profile.QuoteFundingGate - Route *quotationv2.RouteSpecification - ExecutionConditions *quotationv2.ExecutionConditions - BlockReason quotationv2.QuoteBlockReason + Index int + IdempotencyKey string + IntentRef string + Intent model.PaymentIntent + QuoteInput BuildQuoteInput + Steps []*QuoteComputationStep + Funding *gateway_funding_profile.QuoteFundingGate + Route *quotationv2.RouteSpecification + ExecutionConditions *quotationv2.ExecutionConditions + ResolvedSettlementMode paymentv1.SettlementMode + ResolvedFeeTreatment quotationv2.FeeTreatment + BlockReason quotationv2.QuoteBlockReason } // QuoteComputationStep is one planner step in a generic execution graph. diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index 4dccb7de..b5ff27d7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -152,6 +152,8 @@ func (s *QuoteComputationService) buildPlanItem( Route: cloneRouteSpecification(route), ExecutionConditions: cloneExecutionConditions(conditions), } + resolvedSettlementMode := resolvedSettlementModeFromModel(modelIntent.SettlementMode) + resolvedFeeTreatment := resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode) intentRef := strings.TrimSpace(modelIntent.Ref) if intentRef == "" { @@ -159,16 +161,18 @@ func (s *QuoteComputationService) buildPlanItem( } return &QuoteComputationPlanItem{ - Index: index, - IdempotencyKey: itemIdempotencyKey, - IntentRef: intentRef, - Intent: modelIntent, - QuoteInput: quoteInput, - Steps: steps, - Funding: funding, - Route: route, - ExecutionConditions: conditions, - BlockReason: blockReason, + Index: index, + IdempotencyKey: itemIdempotencyKey, + IntentRef: intentRef, + Intent: modelIntent, + QuoteInput: quoteInput, + Steps: steps, + Funding: funding, + Route: route, + ExecutionConditions: conditions, + ResolvedSettlementMode: resolvedSettlementMode, + ResolvedFeeTreatment: resolvedFeeTreatment, + BlockReason: blockReason, }, nil } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_settlement.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_settlement.go index 02da488a..c616a45c 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_settlement.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_settlement.go @@ -13,7 +13,7 @@ func buildRouteSettlement( network string, hops []*quotationv2.RouteHop, ) *quotationv2.RouteSettlement { - modelValue := normalizeSettlementModel(settlementModelString(intent.SettlementMode)) + modelValue := strings.ToLower(strings.TrimSpace(settlementModelString(intent.SettlementMode))) asset := buildRouteSettlementAsset(intent, network, hops) if asset == nil && modelValue == "" { return nil diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go index 3228fd53..9344263c 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go @@ -77,11 +77,11 @@ func buildExecutionConditions( func settlementModelString(mode model.SettlementMode) string { switch mode { case model.SettlementModeFixSource: - return "FIX_SOURCE" + return "fix_source" case model.SettlementModeFixReceived: - return "FIX_RECEIVED" + return "fix_received" default: - return "UNSPECIFIED" + return "fix_source" } } diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint_test.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint_test.go index 8526be60..244bbb87 100644 --- a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint_test.go @@ -4,10 +4,10 @@ import ( "testing" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/protobuf/proto" ) @@ -17,7 +17,7 @@ func TestFingerprintQuotePayment_IgnoresTransportFields(t *testing.T) { base := "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, IdempotencyKey: "idem-a", - Intent: testTransferIntent("10"), + Intent: testQuoteIntent("10"), PreviewOnly: false, InitiatorRef: "actor-1", } @@ -36,7 +36,7 @@ func TestFingerprintQuotePayment_DetectsBusinessPayloadChanges(t *testing.T) { base := "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, IdempotencyKey: "idem-a", - Intent: testTransferIntent("10"), + Intent: testQuoteIntent("10"), InitiatorRef: "actor-1", } @@ -47,7 +47,7 @@ func TestFingerprintQuotePayment_DetectsBusinessPayloadChanges(t *testing.T) { } changedAmount := proto.Clone(base).(*quotationv2.QuotePaymentRequest) - changedAmount.Intent = testTransferIntent("11") + changedAmount.Intent = testQuoteIntent("11") if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(changedAmount); got == want { t.Fatalf("expected different fingerprint for amount change") } @@ -58,9 +58,9 @@ func TestFingerprintQuotePayments_IgnoresTransportFields(t *testing.T) { base := "ationv2.QuotePaymentsRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, IdempotencyKey: "idem-a", - Intents: []*transferv1.TransferIntent{ - testTransferIntent("10"), - testTransferIntent("20"), + Intents: []*quotationv2.QuoteIntent{ + testQuoteIntent("10"), + testQuoteIntent("20"), }, PreviewOnly: false, InitiatorRef: "actor-1", @@ -80,9 +80,9 @@ func TestFingerprintQuotePayments_DetectsBusinessPayloadChanges(t *testing.T) { base := "ationv2.QuotePaymentsRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, IdempotencyKey: "idem-a", - Intents: []*transferv1.TransferIntent{ - testTransferIntent("10"), - testTransferIntent("20"), + Intents: []*quotationv2.QuoteIntent{ + testQuoteIntent("10"), + testQuoteIntent("20"), }, InitiatorRef: "actor-1", } @@ -94,20 +94,21 @@ func TestFingerprintQuotePayments_DetectsBusinessPayloadChanges(t *testing.T) { } reordered := proto.Clone(base).(*quotationv2.QuotePaymentsRequest) - reordered.Intents = []*transferv1.TransferIntent{ - testTransferIntent("20"), - testTransferIntent("10"), + reordered.Intents = []*quotationv2.QuoteIntent{ + testQuoteIntent("20"), + testQuoteIntent("10"), } if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(reordered); got == want { t.Fatalf("expected different fingerprint for intent order change") } } -func testTransferIntent(amount string) *transferv1.TransferIntent { - return &transferv1.TransferIntent{ - Source: endpointWithMethodRef("pm-src"), - Destination: endpointWithMethodRef("pm-dst"), - Amount: &moneyv1.Money{Amount: amount, Currency: "USD"}, +func testQuoteIntent(amount string) *quotationv2.QuoteIntent { + return "ationv2.QuoteIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: amount, Currency: "USD"}, + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, } } diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go index 885e768c..155786d1 100644 --- a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go @@ -6,10 +6,10 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -43,7 +43,7 @@ func (v *QuoteRequestValidatorV2) ValidateQuotePayment(req *quotationv2.QuotePay if err != nil { return nil, err } - if err := validateTransferIntent(req.GetIntent(), "intent"); err != nil { + if err := validateQuoteIntent(req.GetIntent(), "intent"); err != nil { return nil, err } @@ -83,7 +83,7 @@ func (v *QuoteRequestValidatorV2) ValidateQuotePayments(req *quotationv2.QuotePa return nil, merrors.InvalidArgument("intents are required") } for i, intent := range intents { - if err := validateTransferIntent(intent, fmt.Sprintf("intents[%d]", i)); err != nil { + if err := validateQuoteIntent(intent, fmt.Sprintf("intents[%d]", i)); err != nil { return nil, err } } @@ -127,7 +127,7 @@ func validateMeta(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) { return orgRef, orgID, nil } -func validateTransferIntent(intent *transferv1.TransferIntent, field string) error { +func validateQuoteIntent(intent *quotationv2.QuoteIntent, field string) error { if intent == nil { return merrors.InvalidArgument(field + " is required") } @@ -140,9 +140,57 @@ func validateTransferIntent(intent *transferv1.TransferIntent, field string) err if intent.GetAmount() == nil { return merrors.InvalidArgument(field + ".amount is required") } + if err := validateSettlementAndFeeTreatment(intent.GetSettlementMode(), intent.GetFeeTreatment(), field); err != nil { + return err + } return nil } +func validateSettlementAndFeeTreatment( + mode paymentv1.SettlementMode, + feeTreatment quotationv2.FeeTreatment, + field string, +) error { + if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + if feeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return merrors.InvalidArgument(field + ".settlement_mode is required when fee_treatment is set") + } + // Both omitted is allowed and will be normalized by the hydrator. + return nil + } + + expected, ok := expectedFeeTreatmentForMode(mode) + if !ok { + return merrors.InvalidArgument(field + ".settlement_mode is invalid") + } + + if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return nil + } + if feeTreatment != expected { + return merrors.InvalidArgument( + fmt.Sprintf( + "%s.fee_treatment conflicts with settlement_mode %s (expected %s)", + field, + mode.String(), + expected.String(), + ), + ) + } + return nil +} + +func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) (quotationv2.FeeTreatment, bool) { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, true + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, true + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, false + } +} + func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool { if endpoint == nil { return false diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go index 94e8e64b..3d855b89 100644 --- a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go @@ -6,10 +6,10 @@ import ( "testing" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -19,7 +19,7 @@ func TestValidateQuotePayment_Success(t *testing.T) { req := "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()}, IdempotencyKey: "idem-1", - Intent: validTransferIntent(), + Intent: validQuoteIntent(), PreviewOnly: false, InitiatorRef: "actor-1", } @@ -61,7 +61,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) { name: "idempotency required for non-preview", req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, - Intent: validTransferIntent(), + Intent: validQuoteIntent(), PreviewOnly: false, InitiatorRef: "actor-1", }, @@ -72,7 +72,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) { req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", - Intent: validTransferIntent(), + Intent: validQuoteIntent(), PreviewOnly: true, InitiatorRef: "actor-1", }, @@ -83,7 +83,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) { req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", - Intent: validTransferIntent(), + Intent: validQuoteIntent(), PreviewOnly: false, }, checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) }, @@ -93,7 +93,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) { req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: "bad-org"}, IdempotencyKey: "idem-1", - Intent: validTransferIntent(), + Intent: validQuoteIntent(), PreviewOnly: false, InitiatorRef: "actor-1", }, @@ -106,7 +106,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) { req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", - Intent: &transferv1.TransferIntent{ + Intent: "ationv2.QuoteIntent{ Destination: endpointWithMethodRef("pm-dst"), Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, }, @@ -115,6 +115,44 @@ func TestValidateQuotePayment_Rules(t *testing.T) { }, checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") }, }, + { + name: "fee treatment requires settlement mode", + req: "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intent: "ationv2.QuoteIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + }, + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { + return err != nil && strings.Contains(err.Error(), "intent.settlement_mode is required when fee_treatment is set") + }, + }, + { + name: "conflicting fee treatment is rejected", + req: "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intent: "ationv2.QuoteIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { + return err != nil && strings.Contains(err.Error(), "intent.fee_treatment conflicts with settlement_mode") + }, + }, } for _, tc := range testCases { @@ -132,7 +170,7 @@ func TestValidateQuotePayments_Success(t *testing.T) { orgID := bson.NewObjectID() req := "ationv2.QuotePaymentsRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()}, - Intents: []*transferv1.TransferIntent{validTransferIntent(), validTransferIntent()}, + Intents: []*quotationv2.QuoteIntent{validQuoteIntent(), validQuoteIntent()}, PreviewOnly: true, InitiatorRef: "actor-1", } @@ -178,7 +216,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) { name: "idempotency required for non-preview", req: "ationv2.QuotePaymentsRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, - Intents: []*transferv1.TransferIntent{validTransferIntent()}, + Intents: []*quotationv2.QuoteIntent{validQuoteIntent()}, PreviewOnly: false, InitiatorRef: "actor-1", }, @@ -189,7 +227,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) { req: "ationv2.QuotePaymentsRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", - Intents: []*transferv1.TransferIntent{validTransferIntent()}, + Intents: []*quotationv2.QuoteIntent{validQuoteIntent()}, PreviewOnly: true, InitiatorRef: "actor-1", }, @@ -199,7 +237,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) { name: "initiator ref required", req: "ationv2.QuotePaymentsRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, - Intents: []*transferv1.TransferIntent{validTransferIntent()}, + Intents: []*quotationv2.QuoteIntent{validQuoteIntent()}, PreviewOnly: true, }, checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) }, @@ -209,7 +247,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) { req: "ationv2.QuotePaymentsRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", - Intents: []*transferv1.TransferIntent{ + Intents: []*quotationv2.QuoteIntent{ { Source: endpointWithMethodRef("pm-src"), Destination: endpointWithMethodRef("pm-dst"), @@ -234,11 +272,12 @@ func TestValidateQuotePayments_Rules(t *testing.T) { } } -func validTransferIntent() *transferv1.TransferIntent { - return &transferv1.TransferIntent{ - Source: endpointWithMethodRef("pm-src"), - Destination: endpointWithMethodRef("pm-dst"), - Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, +func validQuoteIntent() *quotationv2.QuoteIntent { + return "ationv2.QuoteIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, } } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go index 6f7b51db..f48575ed 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go @@ -5,6 +5,7 @@ import ( feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) @@ -16,17 +17,19 @@ type QuoteMeta struct { } type CanonicalQuote struct { - QuoteRef string - DebitAmount *moneyv1.Money - CreditAmount *moneyv1.Money - TotalCost *moneyv1.Money - FeeLines []*feesv1.DerivedPostingLine - FeeRules []*feesv1.AppliedRule - FXQuote *oraclev1.Quote - Route *quotationv2.RouteSpecification - Conditions *quotationv2.ExecutionConditions - ExpiresAt time.Time - PricedAt time.Time + QuoteRef string + TransferPrincipalAmount *moneyv1.Money + DestinationAmount *moneyv1.Money + PayerTotalDebitAmount *moneyv1.Money + FeeLines []*feesv1.DerivedPostingLine + FeeRules []*feesv1.AppliedRule + FXQuote *oraclev1.Quote + Route *quotationv2.RouteSpecification + Conditions *quotationv2.ExecutionConditions + ResolvedSettlementMode paymentv1.SettlementMode + ResolvedFeeTreatment quotationv2.FeeTreatment + ExpiresAt time.Time + PricedAt time.Time } type QuoteStatus struct { diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go index 0f1a5f9e..a310232e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go @@ -4,7 +4,9 @@ import ( "strings" "time" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -20,22 +22,27 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) { if err != nil { return nil, err } + expiresAt := normalizeQuoteExpiresAt(in.Quote.ExpiresAt, in.Quote.FXQuote) + settlementMode := normalizeResolvedSettlementMode(in.Quote.ResolvedSettlementMode) + feeTreatment := normalizeResolvedFeeTreatment(settlementMode, in.Quote.ResolvedFeeTreatment) result := "ationv2.PaymentQuote{ - Storable: mapStorable(in.Meta), - State: decision.state, - BlockReason: decision.blockReason, - DebitAmount: cloneMoney(in.Quote.DebitAmount), - CreditAmount: cloneMoney(in.Quote.CreditAmount), - TotalCost: cloneMoney(in.Quote.TotalCost), - FeeLines: cloneFeeLines(in.Quote.FeeLines), - FeeRules: cloneFeeRules(in.Quote.FeeRules), - FxQuote: cloneFXQuote(in.Quote.FXQuote), - Route: cloneRoute(in.Quote.Route), - ExecutionConditions: cloneExecutionConditions(in.Quote.Conditions), - QuoteRef: strings.TrimSpace(in.Quote.QuoteRef), - ExpiresAt: tsOrNil(in.Quote.ExpiresAt), - PricedAt: tsOrNil(in.Quote.PricedAt), + Storable: mapStorable(in.Meta), + State: decision.state, + BlockReason: decision.blockReason, + TransferPrincipalAmount: cloneMoney(in.Quote.TransferPrincipalAmount), + DestinationAmount: cloneMoney(in.Quote.DestinationAmount), + PayerTotalDebitAmount: cloneMoney(in.Quote.PayerTotalDebitAmount), + FeeLines: cloneFeeLines(in.Quote.FeeLines), + FeeRules: cloneFeeRules(in.Quote.FeeRules), + FxQuote: cloneFXQuote(in.Quote.FXQuote), + Route: cloneRoute(in.Quote.Route), + ExecutionConditions: cloneExecutionConditions(in.Quote.Conditions), + QuoteRef: strings.TrimSpace(in.Quote.QuoteRef), + ExpiresAt: tsOrNil(expiresAt), + PricedAt: tsOrNil(in.Quote.PricedAt), + ResolvedSettlementMode: settlementMode, + ResolvedFeeTreatment: feeTreatment, } return &MapOutput{ @@ -63,3 +70,50 @@ func tsOrNil(value time.Time) *timestamppb.Timestamp { } return timestamppb.New(value) } + +func normalizeQuoteExpiresAt(expiresAt time.Time, fxQuote *oraclev1.Quote) time.Time { + if expiresAt.IsZero() || fxQuote == nil || fxQuote.GetExpiresAtUnixMs() <= 0 { + return expiresAt + } + fxExpiresAt := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC() + if fxExpiresAt.IsZero() { + return expiresAt + } + if expiresAt.After(fxExpiresAt) { + return fxExpiresAt + } + return expiresAt +} + +func normalizeResolvedSettlementMode(mode paymentv1.SettlementMode) paymentv1.SettlementMode { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return mode + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func normalizeResolvedFeeTreatment( + mode paymentv1.SettlementMode, + feeTreatment quotationv2.FeeTreatment, +) quotationv2.FeeTreatment { + expected := expectedFeeTreatmentForMode(mode) + switch feeTreatment { + case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + if feeTreatment == expected { + return feeTreatment + } + } + return expected +} + +func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index d1464312..40c96e56 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/pkg/merrors" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) @@ -26,15 +27,15 @@ func TestMap_ExecutableQuote(t *testing.T) { }, Quote: CanonicalQuote{ QuoteRef: "q-1", - DebitAmount: &moneyv1.Money{ + TransferPrincipalAmount: &moneyv1.Money{ Amount: "10", Currency: "USD", }, - CreditAmount: &moneyv1.Money{ + DestinationAmount: &moneyv1.Money{ Amount: "9", Currency: "EUR", }, - TotalCost: &moneyv1.Money{ + PayerTotalDebitAmount: &moneyv1.Money{ Amount: "10.2", Currency: "USD", }, @@ -57,8 +58,10 @@ func TestMap_ExecutableQuote(t *testing.T) { PrefundingRequired: false, LiquidityCheckRequiredAtExecution: true, }, - ExpiresAt: expiresAt, - PricedAt: pricedAt, + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + ExpiresAt: expiresAt, + PricedAt: pricedAt, }, Status: QuoteStatus{ State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, @@ -103,8 +106,68 @@ func TestMap_ExecutableQuote(t *testing.T) { if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want { t.Fatalf("unexpected settlement token: got=%q want=%q", got, want) } - if got, want := out.Quote.GetTotalCost().GetAmount(), "10.2"; got != want { - t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) + if got, want := out.Quote.GetPayerTotalDebitAmount().GetAmount(), "10.2"; got != want { + t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want) + } + if got, want := out.Quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := out.Quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected resolved_fee_treatment: got=%s want=%s", got.String(), want.String()) + } +} + +func TestMap_ClampsQuoteExpiresAtToFXExpiry(t *testing.T) { + mapper := New() + quoteExpiresAt := time.Unix(300, 0).UTC() + fxExpiresAt := time.Unix(200, 0).UTC() + + out, err := mapper.Map(MapInput{ + Quote: CanonicalQuote{ + QuoteRef: "q-fx-expiry-clamp", + ExpiresAt: quoteExpiresAt, + FXQuote: &oraclev1.Quote{ExpiresAtUnixMs: fxExpiresAt.UnixMilli()}, + TransferPrincipalAmount: &moneyv1.Money{ + Amount: "1", + Currency: "USDT", + }, + }, + Status: QuoteStatus{ + State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || out.Quote == nil || out.Quote.GetExpiresAt() == nil { + t.Fatalf("expected mapped quote with expires_at") + } + if got, want := out.Quote.GetExpiresAt().AsTime().UnixMilli(), fxExpiresAt.UnixMilli(); got != want { + t.Fatalf("unexpected clamped expires_at: got=%d want=%d", got, want) + } +} + +func TestMap_DefaultsResolvedEconomicsWhenUnset(t *testing.T) { + mapper := New() + out, err := mapper.Map(MapInput{ + Quote: CanonicalQuote{ + TransferPrincipalAmount: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + }, + Status: QuoteStatus{ + State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || out.Quote == nil { + t.Fatalf("expected mapped quote") + } + if got, want := out.Quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("unexpected default resolved_settlement_mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := out.Quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected default resolved_fee_treatment: got=%s want=%s", got.String(), want.String()) } } diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index dd388079..ff787924 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -7,8 +7,9 @@ import ( "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" - transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/grpc" ) @@ -16,13 +17,13 @@ import ( type HydrateOneInput struct { OrganizationRef string InitiatorRef string - Intent *transferv1.TransferIntent + Intent *quotationv2.QuoteIntent } type HydrateManyInput struct { OrganizationRef string InitiatorRef string - Intents []*transferv1.TransferIntent + Intents []*quotationv2.QuoteIntent } type PaymentMethodsClient interface { @@ -76,6 +77,24 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn return nil, merrors.InvalidArgument("intent.amount is required") } + amount := &paymenttypes.Money{ + Amount: strings.TrimSpace(in.Intent.GetAmount().GetAmount()), + Currency: strings.TrimSpace(in.Intent.GetAmount().GetCurrency()), + } + if amount.Amount == "" { + return nil, merrors.InvalidArgument("intent.amount.amount is required") + } + if amount.Currency == "" { + return nil, merrors.InvalidArgument("intent.amount.currency is required") + } + settlementMode, feeTreatment, err := resolveEconomics( + in.Intent.GetSettlementMode(), + in.Intent.GetFeeTreatment(), + ) + if err != nil { + return nil, err + } + source, err := h.hydrateEndpoint( ctx, in.OrganizationRef, @@ -97,16 +116,11 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn return nil, err } - amount := &paymenttypes.Money{ - Amount: strings.TrimSpace(in.Intent.GetAmount().GetAmount()), - Currency: strings.TrimSpace(in.Intent.GetAmount().GetCurrency()), - } - if amount.Amount == "" { - return nil, merrors.InvalidArgument("intent.amount.amount is required") - } - if amount.Currency == "" { - return nil, merrors.InvalidArgument("intent.amount.currency is required") + settlementCurrency := strings.ToUpper(strings.TrimSpace(in.Intent.GetSettlementCurrency())) + if settlementCurrency == "" { + settlementCurrency = strings.ToUpper(strings.TrimSpace(amount.Currency)) } + requiresFX := !strings.EqualFold(amount.Currency, settlementCurrency) intent := &QuoteIntent{ Ref: h.newRef(), @@ -115,11 +129,14 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn Destination: destination, Amount: amount, Comment: strings.TrimSpace(in.Intent.GetComment()), - SettlementMode: QuoteSettlementModeUnspecified, - SettlementCurrency: amount.Currency, - RequiresFX: false, + SettlementMode: settlementMode, + FeeTreatment: feeTreatment, + SettlementCurrency: settlementCurrency, + RequiresFX: requiresFX, Attributes: map[string]string{ - "initiator_ref": strings.TrimSpace(in.InitiatorRef), + "initiator_ref": strings.TrimSpace(in.InitiatorRef), + "settlement_mode": string(settlementMode), + "fee_treatment": string(feeTreatment), }, } if intent.Comment != "" { @@ -146,3 +163,67 @@ func (h *TransferIntentHydrator) HydrateMany(ctx context.Context, in HydrateMany } return out, nil } + +func resolveEconomics( + mode paymentv1.SettlementMode, + feeTreatment quotationv2.FeeTreatment, +) (QuoteSettlementMode, QuoteFeeTreatment, error) { + if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED && + feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return QuoteSettlementModeFixSource, QuoteFeeTreatmentAddToSource, nil + } + if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is required when fee_treatment is set") + } + + resolvedMode := settlementModeFromProto(mode) + if resolvedMode == QuoteSettlementModeUnspecified { + return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is invalid") + } + expectedFeeTreatment := feeTreatmentForMode(resolvedMode) + if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return resolvedMode, expectedFeeTreatment, nil + } + + resolvedFeeTreatment := feeTreatmentFromProto(feeTreatment) + if resolvedFeeTreatment == QuoteFeeTreatmentUnspecified { + return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment is invalid") + } + if resolvedFeeTreatment != expectedFeeTreatment { + return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment conflicts with settlement_mode") + } + return resolvedMode, resolvedFeeTreatment, nil +} + +func settlementModeFromProto(mode paymentv1.SettlementMode) QuoteSettlementMode { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE: + return QuoteSettlementModeFixSource + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return QuoteSettlementModeFixReceived + default: + return QuoteSettlementModeUnspecified + } +} + +func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment { + switch value { + case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE: + return QuoteFeeTreatmentAddToSource + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + return QuoteFeeTreatmentDeductFromDestination + default: + return QuoteFeeTreatmentUnspecified + } +} + +func feeTreatmentForMode(mode QuoteSettlementMode) QuoteFeeTreatment { + switch mode { + case QuoteSettlementModeFixSource: + return QuoteFeeTreatmentAddToSource + case QuoteSettlementModeFixReceived: + return QuoteFeeTreatmentDeductFromDestination + default: + return QuoteFeeTreatmentUnspecified + } +} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go index 3f6852c3..419b2034 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go @@ -19,6 +19,14 @@ const ( QuoteSettlementModeFixReceived QuoteSettlementMode = "fix_received" ) +type QuoteFeeTreatment string + +const ( + QuoteFeeTreatmentUnspecified QuoteFeeTreatment = "unspecified" + QuoteFeeTreatmentAddToSource QuoteFeeTreatment = "add_to_source" + QuoteFeeTreatmentDeductFromDestination QuoteFeeTreatment = "deduct_from_destination" +) + type QuoteEndpointType string const ( @@ -75,6 +83,7 @@ type QuoteIntent struct { Amount *paymenttypes.Money Comment string SettlementMode QuoteSettlementMode + FeeTreatment QuoteFeeTreatment SettlementCurrency string RequiresFX bool Attributes map[string]string diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go index 7f9dd334..b10e1057 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go @@ -8,11 +8,12 @@ import ( pkgmodel "github.com/tech/sendico/pkg/model" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1" storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" - transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/grpc" ) @@ -20,7 +21,7 @@ import ( func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) { h := New(nil, WithRefFactory(func() string { return "q-intent-1" })) tag := "12345" - intent := &transferv1.TransferIntent{ + intent := "ationv2.QuoteIntent{ Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{ @@ -86,6 +87,12 @@ func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) { if got.SettlementCurrency != "USDT" { t.Fatalf("expected settlement currency USDT, got %q", got.SettlementCurrency) } + if got.SettlementMode != QuoteSettlementModeFixSource { + t.Fatalf("expected default settlement mode fix_source, got %s", got.SettlementMode) + } + if got.FeeTreatment != QuoteFeeTreatmentAddToSource { + t.Fatalf("expected default fee treatment add_to_source, got %s", got.FeeTreatment) + } if got.Amount == nil || got.Amount.Amount != "10.25" { t.Fatalf("unexpected amount: %#v", got.Amount) } @@ -120,7 +127,7 @@ func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) { }, }) - intent := &transferv1.TransferIntent{ + intent := "ationv2.QuoteIntent{ Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: methodRef}, }, @@ -201,7 +208,7 @@ func TestHydrateOne_ResolvesPayeeRefViaPrivateMethod(t *testing.T) { }, }) - intent := &transferv1.TransferIntent{ + intent := "ationv2.QuoteIntent{ Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{ @@ -275,7 +282,7 @@ func TestHydrateOne_ResolvesInlineAccountPaymentMethodViaPrivateMethod(t *testin }, }) - intent := &transferv1.TransferIntent{ + intent := "ationv2.QuoteIntent{ Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{ @@ -327,7 +334,7 @@ func TestHydrateOne_ResolvesInlineAccountPaymentMethodViaPrivateMethod(t *testin func TestHydrateOne_ReferenceWithoutMethodsClientFails(t *testing.T) { h := New(nil) - intent := &transferv1.TransferIntent{ + intent := "ationv2.QuoteIntent{ Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: bson.NewObjectID().Hex()}, }, @@ -356,7 +363,7 @@ func TestHydrateOne_ReferenceWithoutMethodsClientFails(t *testing.T) { func TestHydrateMany_IndexesError(t *testing.T) { h := New(nil) - intents := []*transferv1.TransferIntent{ + intents := []*quotationv2.QuoteIntent{ { Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{ @@ -422,6 +429,26 @@ func TestHydrateMany_IndexesError(t *testing.T) { } } +func TestHydrateOne_RejectsConflictingEconomics(t *testing.T) { + h := New(nil) + intent := "ationv2.QuoteIntent{ + Source: endpointWithMethodRef(bson.NewObjectID().Hex()), + Destination: endpointWithMethodRef(bson.NewObjectID().Hex()), + Amount: newMoney("1", "USD"), + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + } + + _, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err == nil || !strings.Contains(err.Error(), "fee_treatment conflicts with settlement_mode") { + t.Fatalf("expected settlement/fee conflict error, got %v", err) + } +} + type fakeMethodsClient struct { getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error) } @@ -444,6 +471,14 @@ func newMoney(amount, currency string) *moneyv1.Money { } } +func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint { + return &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{ + PaymentMethodRef: methodRef, + }, + } +} + func mustMarshalBSON(t *testing.T, value any) []byte { t.Helper() data, err := bson.Marshal(value) diff --git a/api/proto/common/payment/v1/card.proto b/api/proto/common/payment/v1/card.proto index 6b55b168..f9a2d3cc 100644 --- a/api/proto/common/payment/v1/card.proto +++ b/api/proto/common/payment/v1/card.proto @@ -4,8 +4,6 @@ package common.payment.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; -import "google/protobuf/wrappers.proto"; - // ------------------------- // Card network (payment system) diff --git a/api/proto/common/payment/v1/settlement.proto b/api/proto/common/payment/v1/settlement.proto index 02174885..ea84801b 100644 --- a/api/proto/common/payment/v1/settlement.proto +++ b/api/proto/common/payment/v1/settlement.proto @@ -8,6 +8,6 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;payment // SettlementMode defines how to treat fees/FX variance for payouts. enum SettlementMode { SETTLEMENT_UNSPECIFIED = 0; - SETTLEMENT_FIX_SOURCE = 1; // customer pays fees; sent amount fixed - SETTLEMENT_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes + SETTLEMENT_FIX_SOURCE = 1; + SETTLEMENT_FIX_RECEIVED = 2; } diff --git a/api/proto/payments/quotation/v2/interface.proto b/api/proto/payments/quotation/v2/interface.proto index 32a5dec5..e85fae20 100644 --- a/api/proto/payments/quotation/v2/interface.proto +++ b/api/proto/payments/quotation/v2/interface.proto @@ -8,12 +8,12 @@ import "google/protobuf/timestamp.proto"; import "api/proto/common/storable/v1/storable.proto"; import "api/proto/common/money/v1/money.proto"; import "api/proto/common/payment/v1/asset.proto"; +import "api/proto/common/payment/v1/settlement.proto"; import "api/proto/billing/fees/v1/fees.proto"; import "api/proto/oracle/v1/oracle.proto"; enum QuoteState { QUOTE_STATE_UNSPECIFIED = 0; - QUOTE_STATE_INDICATIVE = 1; QUOTE_STATE_EXECUTABLE = 2; QUOTE_STATE_BLOCKED = 3; @@ -22,7 +22,6 @@ enum QuoteState { enum QuoteBlockReason { QUOTE_BLOCK_REASON_UNSPECIFIED = 0; - QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1; QUOTE_BLOCK_REASON_LIMIT_BLOCKED = 2; QUOTE_BLOCK_REASON_RISK_BLOCKED = 3; @@ -46,6 +45,12 @@ enum RouteHopRole { ROUTE_HOP_ROLE_DESTINATION = 3; } +enum FeeTreatment { + FEE_TREATMENT_UNSPECIFIED = 0; + FEE_TREATMENT_ADD_TO_SOURCE = 1; + FEE_TREATMENT_DEDUCT_FROM_DESTINATION = 2; +} + message RouteHop { uint32 index = 1; string rail = 2; @@ -67,13 +72,11 @@ message RouteSpecification { string rail = 1; string provider = 2; string payout_method = 3; - reserved 4, 5; - reserved "settlement_asset", "settlement_model"; - string network = 6; - string route_ref = 7; - string pricing_profile_ref = 8; - repeated RouteHop hops = 9; - RouteSettlement settlement = 10; + string network = 4; + string route_ref = 5; + string pricing_profile_ref = 6; + repeated RouteHop hops = 7; + RouteSettlement settlement = 8; } // Execution assumptions and constraints evaluated at quotation time. @@ -91,24 +94,27 @@ message ExecutionConditions { message PaymentQuote { common.storable.v1.Storable storable = 1; QuoteState state = 2; - QuoteBlockReason block_reason = 4; - reserved 3, 13; - reserved "kind", "lifecycle", "executable"; + QuoteBlockReason block_reason = 3; - common.money.v1.Money debit_amount = 5; - common.money.v1.Money credit_amount = 6; + // Transfer principal amount before fees. + common.money.v1.Money transfer_principal_amount = 4; + // Expected destination settlement amount. + common.money.v1.Money destination_amount = 5; - repeated fees.v1.DerivedPostingLine fee_lines = 7; - repeated fees.v1.AppliedRule fee_rules = 8; + repeated fees.v1.DerivedPostingLine fee_lines = 6; + repeated fees.v1.AppliedRule fee_rules = 7; - oracle.v1.Quote fx_quote = 9; + oracle.v1.Quote fx_quote = 8; - string quote_ref = 10; + string quote_ref = 9; - google.protobuf.Timestamp expires_at = 11; - google.protobuf.Timestamp priced_at = 12; + google.protobuf.Timestamp expires_at = 10; + google.protobuf.Timestamp priced_at = 11; - RouteSpecification route = 14; - ExecutionConditions execution_conditions = 15; - common.money.v1.Money total_cost = 16; + RouteSpecification route = 12; + ExecutionConditions execution_conditions = 13; + // Total amount expected to be debited from payer side (principal +/- fee lines in source currency). + common.money.v1.Money payer_total_debit_amount = 14; + common.payment.v1.SettlementMode resolved_settlement_mode = 15; + FeeTreatment resolved_fee_treatment = 16; } diff --git a/api/proto/payments/quotation/v2/quotation.proto b/api/proto/payments/quotation/v2/quotation.proto index 64c6bb59..d61d709f 100644 --- a/api/proto/payments/quotation/v2/quotation.proto +++ b/api/proto/payments/quotation/v2/quotation.proto @@ -5,14 +5,25 @@ package payments.quotation.v2; option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v2;quotationv2"; import "api/proto/payments/shared/v1/shared.proto"; -import "api/proto/payments/transfer/v1/transfer.proto"; +import "api/proto/common/money/v1/money.proto"; +import "api/proto/common/payment/v1/settlement.proto"; +import "api/proto/payments/endpoint/v1/endpoint.proto"; import "api/proto/payments/quotation/v2/interface.proto"; +message QuoteIntent { + payments.endpoint.v1.PaymentEndpoint source = 1; + payments.endpoint.v1.PaymentEndpoint destination = 2; + common.money.v1.Money amount = 3; + common.payment.v1.SettlementMode settlement_mode = 4; + payments.quotation.v2.FeeTreatment fee_treatment = 5; + string settlement_currency = 6; + string comment = 7; +} message QuotePaymentRequest { payments.shared.v1.RequestMeta meta = 1; string idempotency_key = 2; - payments.transfer.v1.TransferIntent intent = 3; + payments.quotation.v2.QuoteIntent intent = 3; bool preview_only = 4; string initiator_ref = 5; } @@ -25,7 +36,7 @@ message QuotePaymentResponse { message QuotePaymentsRequest { payments.shared.v1.RequestMeta meta = 1; string idempotency_key = 2; - repeated payments.transfer.v1.TransferIntent intents = 3; + repeated payments.quotation.v2.QuoteIntent intents = 3; bool preview_only = 4; string initiator_ref = 5; } diff --git a/api/server/go.mod b/api/server/go.mod index 42bec138..0eae9b59 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -16,8 +16,8 @@ replace github.com/tech/sendico/gateway/tron => ../gateway/tron require ( github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.8 - github.com/aws/aws-sdk-go-v2/credentials v1.19.8 + github.com/aws/aws-sdk-go-v2/config v1.32.9 + github.com/aws/aws-sdk-go-v2/credentials v1.19.9 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 @@ -64,7 +64,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect diff --git a/api/server/go.sum b/api/server/go.sum index f9e01e94..75928d9a 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6ce github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= -github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= +github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= +github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= @@ -36,8 +36,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIM github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 31f36674..27a3fdc4 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -2,6 +2,7 @@ package sresponse import ( "net/http" + "strings" "time" "github.com/tech/sendico/pkg/api/http/response" @@ -148,8 +149,8 @@ func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine { result = append(result, FeeLine{ LedgerAccountRef: line.GetLedgerAccountRef(), Amount: toMoney(line.GetMoney()), - LineType: line.GetLineType().String(), - Side: line.GetSide().String(), + LineType: enumJSONName(line.GetLineType().String()), + Side: enumJSONName(line.GetSide().String()), Meta: line.GetMeta(), }) } @@ -178,7 +179,7 @@ func toFxQuote(q *oraclev1.Quote) *FxQuote { QuoteRef: q.GetQuoteRef(), BaseCurrency: base, QuoteCurrency: quote, - Side: q.GetSide().String(), + Side: enumJSONName(q.GetSide().String()), Price: q.GetPrice().GetValue(), BaseAmount: toMoney(q.GetBaseAmount()), QuoteAmount: toMoney(q.GetQuoteAmount()), @@ -260,11 +261,15 @@ func toPayment(p *sharedv1.Payment) *Payment { return &Payment{ PaymentRef: p.GetPaymentRef(), IdempotencyKey: p.GetIdempotencyKey(), - State: p.GetState().String(), - FailureCode: p.GetFailureCode().String(), + State: enumJSONName(p.GetState().String()), + FailureCode: enumJSONName(p.GetFailureCode().String()), FailureReason: p.GetFailureReason(), LastQuote: toPaymentQuote(p.GetLastQuote()), CreatedAt: p.GetCreatedAt().AsTime(), Meta: p.GetMetadata(), } } + +func enumJSONName(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +}