quotation v2 service

This commit is contained in:
Stephan D
2026-02-18 22:06:51 +01:00
parent a33be56247
commit 1c5d3d202b
30 changed files with 799 additions and 231 deletions

View File

@@ -160,8 +160,8 @@ generate-api:
# Generate Flutter code (json_serializable, etc.) # Generate Flutter code (json_serializable, etc.)
generate-frontend: generate-frontend:
@echo "$(GREEN)Generating Flutter code...$(NC)" @echo "$(GREEN)Generating Flutter code...$(NC)"
@cd frontend/pshared && flutter pub run build_runner build --delete-conflicting-outputs @cd frontend/pshared && dart run build_runner build --delete-conflicting-outputs
@cd frontend/pweb && flutter pub 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)" @echo "$(GREEN)✅ Flutter code generation complete$(NC)"
# Clean everything # Clean everything

View File

@@ -6,8 +6,8 @@ replace github.com/tech/sendico/pkg => ../../pkg
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.1 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/config v1.32.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.8 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/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/jung-kurt/gofpdf v1.16.2 github.com/jung-kurt/gofpdf v1.16.2
github.com/prometheus/client_golang v1.23.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/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/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/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/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect

View File

@@ -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 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 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/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.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= 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.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= 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.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= 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 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/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= 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/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 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/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.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
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/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 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/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= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=

View File

@@ -11,6 +11,7 @@ import (
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
@@ -73,21 +74,36 @@ func canonicalFromSnapshot(
PricedAt: pricedAt, PricedAt: pricedAt,
} }
} }
resolvedSettlementMode := resolvedSettlementModeFromSnapshot(snapshot)
return quote_response_mapper_v2.CanonicalQuote{ return quote_response_mapper_v2.CanonicalQuote{
QuoteRef: firstNonEmpty(snapshot.QuoteRef, fallbackQuoteRef), QuoteRef: firstNonEmpty(snapshot.QuoteRef, fallbackQuoteRef),
DebitAmount: protoMoneyFromModel(snapshot.DebitAmount), TransferPrincipalAmount: protoMoneyFromModel(snapshot.DebitAmount),
CreditAmount: protoMoneyFromModel(snapshot.ExpectedSettlementAmount), DestinationAmount: protoMoneyFromModel(snapshot.ExpectedSettlementAmount),
TotalCost: protoMoneyFromModel(snapshot.TotalCost), PayerTotalDebitAmount: protoMoneyFromModel(snapshot.TotalCost),
FeeLines: feeLinesToProto(snapshot.FeeLines), FeeLines: feeLinesToProto(snapshot.FeeLines),
FeeRules: feeRulesToProto(snapshot.FeeRules), FeeRules: feeRulesToProto(snapshot.FeeRules),
Route: protoRouteFromModel(snapshot.Route), Route: protoRouteFromModel(snapshot.Route),
Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions), Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions),
FXQuote: protoFXQuoteFromModel(snapshot.FXQuote), FXQuote: protoFXQuoteFromModel(snapshot.FXQuote),
ResolvedSettlementMode: resolvedSettlementMode,
ResolvedFeeTreatment: resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode),
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
PricedAt: pricedAt, 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
}
}
func modelMoneyFromProto(src *moneyv1.Money) *paymenttypes.Money { func modelMoneyFromProto(src *moneyv1.Money) *paymenttypes.Money {
if src == nil { if src == nil {
return nil return nil

View File

@@ -4,7 +4,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/tech/sendico/payments/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" 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) { 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())), 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
}
}

View File

@@ -23,7 +23,6 @@ import (
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" 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" "go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "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 { if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason, got=%s", got.String()) t.Fatalf("expected empty block reason, got=%s", got.String())
} }
if got, want := quote.GetDebitAmount().GetAmount(), "100"; got != want { if got, want := quote.GetTransferPrincipalAmount().GetAmount(), "100"; got != want {
t.Fatalf("unexpected debit amount: got=%q want=%q", got, want) t.Fatalf("unexpected principal amount: got=%q want=%q", got, want)
} }
if got, want := quote.GetDebitAmount().GetCurrency(), "USDT"; got != want { if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want {
t.Fatalf("unexpected debit currency: got=%q want=%q", got, want) t.Fatalf("unexpected principal currency: got=%q want=%q", got, want)
} }
if got, want := quote.GetCreditAmount().GetAmount(), "9150"; got != want { if got, want := quote.GetDestinationAmount().GetAmount(), "9150"; got != want {
t.Fatalf("unexpected credit amount: got=%q want=%q", got, want) t.Fatalf("unexpected destination amount: got=%q want=%q", got, want)
} }
if got, want := quote.GetCreditAmount().GetCurrency(), "RUB"; got != want { if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want {
t.Fatalf("unexpected credit currency: got=%q want=%q", got, want) t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
} }
if got, want := quote.GetTotalCost().GetAmount(), "101.8"; got != want { if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "101.8"; got != want {
t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
} }
if got, want := quote.GetTotalCost().GetCurrency(), "USDT"; got != want { if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want {
t.Fatalf("unexpected total_cost currency: got=%q want=%q", 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 { if got, want := len(quote.GetFeeLines()), 2; got != want {
t.Fatalf("unexpected fee lines count: got=%d want=%d", 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 { if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want {
t.Fatalf("unexpected fx quote currency: got=%q want=%q", 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 { if quote.GetRoute() == nil {
t.Fatalf("expected route specification") 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 { if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
t.Fatalf("unexpected route settlement token: got=%q want=%q", 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 { if quote.GetExecutionConditions() == nil {
t.Fatalf("expected execution conditions") t.Fatalf("expected execution conditions")
} }
@@ -146,6 +151,12 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
if quote.GetExecutionConditions().GetPrefundingRequired() { if quote.GetExecutionConditions().GetPrefundingRequired() {
t.Fatalf("expected prefunding_required=false") 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). // Verify that idempotent reuse keeps full quote payload (including fee lines/rules).
reused, err := svc.ProcessQuotePayment(context.Background(), req) 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)) 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 := &quotationv2.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) { func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC() now := time.Unix(1_700_000_000, 0).UTC()
orgID := bson.NewObjectID() orgID := bson.NewObjectID()
@@ -192,7 +264,7 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
IdempotencyKey: "idem-batch-usdt-rub", IdempotencyKey: "idem-batch-usdt-rub",
InitiatorRef: "initiator-42", InitiatorRef: "initiator-42",
PreviewOnly: false, PreviewOnly: false,
Intents: []*transferv1.TransferIntent{ Intents: []*quotationv2.QuoteIntent{
makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"), makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"),
makeTransferIntent(t, "125", "USDT", "wallet-usdt-source", "4222222222222222", "RU"), makeTransferIntent(t, "125", "USDT", "wallet-usdt-source", "4222222222222222", "RU"),
makeTransferIntent(t, "80", "USDT", "wallet-usdt-source", "4333333333333333", "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 { 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()) t.Fatalf("unexpected quote state for item %d: got=%s want=%s", i, got.String(), want.String())
} }
if quote.GetDebitAmount().GetCurrency() != "USDT" { if quote.GetTransferPrincipalAmount().GetCurrency() != "USDT" {
t.Fatalf("unexpected debit currency for item %d: %q", i, quote.GetDebitAmount().GetCurrency()) t.Fatalf("unexpected principal currency for item %d: %q", i, quote.GetTransferPrincipalAmount().GetCurrency())
} }
if quote.GetCreditAmount().GetCurrency() != "RUB" { if quote.GetDestinationAmount().GetCurrency() != "RUB" {
t.Fatalf("unexpected credit currency for item %d: %q", i, quote.GetCreditAmount().GetCurrency()) t.Fatalf("unexpected destination currency for item %d: %q", i, quote.GetDestinationAmount().GetCurrency())
} }
if quote.GetRoute() == nil { if quote.GetRoute() == nil {
t.Fatalf("expected route for item %d", i) 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 { 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) 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 { if quote.GetExecutionConditions() == nil {
t.Fatalf("expected execution conditions for item %d", i) 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 { 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) 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{ 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 { if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want) t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
} }
if got, want := quote.GetTotalCost().GetAmount(), "102.4"; got != want { if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "102.4"; got != want {
t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
} }
} }
@@ -427,7 +514,7 @@ func makeTransferIntent(
sourceWalletID string, sourceWalletID string,
destinationPAN string, destinationPAN string,
destinationCountry string, destinationCountry string,
) *transferv1.TransferIntent { ) *quotationv2.QuoteIntent {
t.Helper() t.Helper()
walletData, err := bson.Marshal(pkgmodel.WalletPaymentData{WalletID: sourceWalletID}) walletData, err := bson.Marshal(pkgmodel.WalletPaymentData{WalletID: sourceWalletID})
@@ -446,7 +533,7 @@ func makeTransferIntent(
t.Fatalf("failed to marshal card method data: %v", err) t.Fatalf("failed to marshal card method data: %v", err)
} }
return &transferv1.TransferIntent{ return &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{
@@ -480,6 +567,8 @@ type fakeQuoteCore struct {
now time.Time now time.Time
quoteRequestIdempotencyKeys []string 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) { 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", Base: "USDT",
Quote: "RUB", Quote: "RUB",
}, },
Side: fxv1.Side_BUY_BASE_SELL_QUOTE, Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
Price: &moneyv1.Decimal{Value: rate.String()}, Price: &moneyv1.Decimal{Value: rate.String()},
BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"}, BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"},
QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: "RUB"}, 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", Provider: "test-oracle",
RateRef: "rate-usdt-rub", RateRef: "rate-usdt-rub",
Firm: true, Firm: true,
PricedAt: timestamppb.New(f.now), 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 { func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {

View File

@@ -135,17 +135,25 @@ func (p *singleIntentProcessorV2) Process(
canonical := quote_response_mapper_v2.CanonicalQuote{ canonical := quote_response_mapper_v2.CanonicalQuote{
QuoteRef: p.quoteRef, QuoteRef: p.quoteRef,
DebitAmount: cloneProtoMoney(result.Quote.DebitAmount), TransferPrincipalAmount: cloneProtoMoney(result.Quote.DebitAmount),
CreditAmount: cloneProtoMoney(result.Quote.CreditAmount), DestinationAmount: cloneProtoMoney(result.Quote.CreditAmount),
TotalCost: cloneProtoMoney(result.Quote.TotalCost), PayerTotalDebitAmount: cloneProtoMoney(result.Quote.TotalCost),
FeeLines: result.Quote.FeeLines, FeeLines: result.Quote.FeeLines,
FeeRules: result.Quote.FeeRules, FeeRules: result.Quote.FeeRules,
FXQuote: result.Quote.FXQuote, FXQuote: result.Quote.FXQuote,
Route: result.Quote.Route, Route: result.Quote.Route,
Conditions: result.Quote.ExecutionConditions, Conditions: result.Quote.ExecutionConditions,
ResolvedSettlementMode: result.Quote.ResolvedSettlementMode,
ResolvedFeeTreatment: result.Quote.ResolvedFeeTreatment,
ExpiresAt: result.ExpiresAt, ExpiresAt: result.ExpiresAt,
PricedAt: p.pricedAt, 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{ mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{
Quote: canonical, Quote: canonical,
@@ -157,12 +165,16 @@ func (p *singleIntentProcessorV2) Process(
if mapped == nil || mapped.Quote == nil { if mapped == nil || mapped.Quote == nil {
return nil, merrors.InvalidArgument("mapped quote is required") 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{ p.collector.Add(&itemProcessDetail{
Index: in.Item.Index, Index: in.Item.Index,
Intent: planItem.Intent, Intent: planItem.Intent,
Quote: result.Quote, Quote: result.Quote,
ExpiresAt: result.ExpiresAt, ExpiresAt: expiresAt,
Status: status, Status: status,
}) })

View File

@@ -27,6 +27,18 @@ func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *Co
if src.TotalCost == nil { if src.TotalCost == nil {
src.TotalCost = deriveTotalCost(src.DebitAmount, src.FeeLines) 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 return src
} }

View File

@@ -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
}
}

View File

@@ -103,6 +103,6 @@ func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) mode
case transfer_intent_hydrator.QuoteSettlementModeFixReceived: case transfer_intent_hydrator.QuoteSettlementModeFixReceived:
return model.SettlementModeFixReceived return model.SettlementModeFixReceived
default: default:
return model.SettlementModeUnspecified return model.SettlementModeFixSource
} }
} }

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/model/account_role"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -43,6 +44,8 @@ type ComputedQuote struct {
FXQuote *oraclev1.Quote FXQuote *oraclev1.Quote
Route *quotationv2.RouteSpecification Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions ExecutionConditions *quotationv2.ExecutionConditions
ResolvedSettlementMode paymentv1.SettlementMode
ResolvedFeeTreatment quotationv2.FeeTreatment
} }
// QuoteComputationPlan is an orchestration plan for quote computations. // QuoteComputationPlan is an orchestration plan for quote computations.
@@ -67,6 +70,8 @@ type QuoteComputationPlanItem struct {
Funding *gateway_funding_profile.QuoteFundingGate Funding *gateway_funding_profile.QuoteFundingGate
Route *quotationv2.RouteSpecification Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions ExecutionConditions *quotationv2.ExecutionConditions
ResolvedSettlementMode paymentv1.SettlementMode
ResolvedFeeTreatment quotationv2.FeeTreatment
BlockReason quotationv2.QuoteBlockReason BlockReason quotationv2.QuoteBlockReason
} }

View File

@@ -152,6 +152,8 @@ func (s *QuoteComputationService) buildPlanItem(
Route: cloneRouteSpecification(route), Route: cloneRouteSpecification(route),
ExecutionConditions: cloneExecutionConditions(conditions), ExecutionConditions: cloneExecutionConditions(conditions),
} }
resolvedSettlementMode := resolvedSettlementModeFromModel(modelIntent.SettlementMode)
resolvedFeeTreatment := resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode)
intentRef := strings.TrimSpace(modelIntent.Ref) intentRef := strings.TrimSpace(modelIntent.Ref)
if intentRef == "" { if intentRef == "" {
@@ -168,6 +170,8 @@ func (s *QuoteComputationService) buildPlanItem(
Funding: funding, Funding: funding,
Route: route, Route: route,
ExecutionConditions: conditions, ExecutionConditions: conditions,
ResolvedSettlementMode: resolvedSettlementMode,
ResolvedFeeTreatment: resolvedFeeTreatment,
BlockReason: blockReason, BlockReason: blockReason,
}, nil }, nil
} }

View File

@@ -13,7 +13,7 @@ func buildRouteSettlement(
network string, network string,
hops []*quotationv2.RouteHop, hops []*quotationv2.RouteHop,
) *quotationv2.RouteSettlement { ) *quotationv2.RouteSettlement {
modelValue := normalizeSettlementModel(settlementModelString(intent.SettlementMode)) modelValue := strings.ToLower(strings.TrimSpace(settlementModelString(intent.SettlementMode)))
asset := buildRouteSettlementAsset(intent, network, hops) asset := buildRouteSettlementAsset(intent, network, hops)
if asset == nil && modelValue == "" { if asset == nil && modelValue == "" {
return nil return nil

View File

@@ -77,11 +77,11 @@ func buildExecutionConditions(
func settlementModelString(mode model.SettlementMode) string { func settlementModelString(mode model.SettlementMode) string {
switch mode { switch mode {
case model.SettlementModeFixSource: case model.SettlementModeFixSource:
return "FIX_SOURCE" return "fix_source"
case model.SettlementModeFixReceived: case model.SettlementModeFixReceived:
return "FIX_RECEIVED" return "fix_received"
default: default:
return "UNSPECIFIED" return "fix_source"
} }
} }

View File

@@ -4,10 +4,10 @@ import (
"testing" "testing"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" 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" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" 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" "go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
@@ -17,7 +17,7 @@ func TestFingerprintQuotePayment_IgnoresTransportFields(t *testing.T) {
base := &quotationv2.QuotePaymentRequest{ base := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a", IdempotencyKey: "idem-a",
Intent: testTransferIntent("10"), Intent: testQuoteIntent("10"),
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
} }
@@ -36,7 +36,7 @@ func TestFingerprintQuotePayment_DetectsBusinessPayloadChanges(t *testing.T) {
base := &quotationv2.QuotePaymentRequest{ base := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a", IdempotencyKey: "idem-a",
Intent: testTransferIntent("10"), Intent: testQuoteIntent("10"),
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
} }
@@ -47,7 +47,7 @@ func TestFingerprintQuotePayment_DetectsBusinessPayloadChanges(t *testing.T) {
} }
changedAmount := proto.Clone(base).(*quotationv2.QuotePaymentRequest) 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 { if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(changedAmount); got == want {
t.Fatalf("expected different fingerprint for amount change") t.Fatalf("expected different fingerprint for amount change")
} }
@@ -58,9 +58,9 @@ func TestFingerprintQuotePayments_IgnoresTransportFields(t *testing.T) {
base := &quotationv2.QuotePaymentsRequest{ base := &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a", IdempotencyKey: "idem-a",
Intents: []*transferv1.TransferIntent{ Intents: []*quotationv2.QuoteIntent{
testTransferIntent("10"), testQuoteIntent("10"),
testTransferIntent("20"), testQuoteIntent("20"),
}, },
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
@@ -80,9 +80,9 @@ func TestFingerprintQuotePayments_DetectsBusinessPayloadChanges(t *testing.T) {
base := &quotationv2.QuotePaymentsRequest{ base := &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a", IdempotencyKey: "idem-a",
Intents: []*transferv1.TransferIntent{ Intents: []*quotationv2.QuoteIntent{
testTransferIntent("10"), testQuoteIntent("10"),
testTransferIntent("20"), testQuoteIntent("20"),
}, },
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
} }
@@ -94,20 +94,21 @@ func TestFingerprintQuotePayments_DetectsBusinessPayloadChanges(t *testing.T) {
} }
reordered := proto.Clone(base).(*quotationv2.QuotePaymentsRequest) reordered := proto.Clone(base).(*quotationv2.QuotePaymentsRequest)
reordered.Intents = []*transferv1.TransferIntent{ reordered.Intents = []*quotationv2.QuoteIntent{
testTransferIntent("20"), testQuoteIntent("20"),
testTransferIntent("10"), testQuoteIntent("10"),
} }
if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(reordered); got == want { if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(reordered); got == want {
t.Fatalf("expected different fingerprint for intent order change") t.Fatalf("expected different fingerprint for intent order change")
} }
} }
func testTransferIntent(amount string) *transferv1.TransferIntent { func testQuoteIntent(amount string) *quotationv2.QuoteIntent {
return &transferv1.TransferIntent{ return &quotationv2.QuoteIntent{
Source: endpointWithMethodRef("pm-src"), Source: endpointWithMethodRef("pm-src"),
Destination: endpointWithMethodRef("pm-dst"), Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: amount, Currency: "USD"}, Amount: &moneyv1.Money{Amount: amount, Currency: "USD"},
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
} }
} }

View File

@@ -6,10 +6,10 @@ import (
"strings" "strings"
"github.com/tech/sendico/pkg/merrors" "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" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" 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" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -43,7 +43,7 @@ func (v *QuoteRequestValidatorV2) ValidateQuotePayment(req *quotationv2.QuotePay
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := validateTransferIntent(req.GetIntent(), "intent"); err != nil { if err := validateQuoteIntent(req.GetIntent(), "intent"); err != nil {
return nil, err return nil, err
} }
@@ -83,7 +83,7 @@ func (v *QuoteRequestValidatorV2) ValidateQuotePayments(req *quotationv2.QuotePa
return nil, merrors.InvalidArgument("intents are required") return nil, merrors.InvalidArgument("intents are required")
} }
for i, intent := range intents { 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 return nil, err
} }
} }
@@ -127,7 +127,7 @@ func validateMeta(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) {
return orgRef, orgID, nil return orgRef, orgID, nil
} }
func validateTransferIntent(intent *transferv1.TransferIntent, field string) error { func validateQuoteIntent(intent *quotationv2.QuoteIntent, field string) error {
if intent == nil { if intent == nil {
return merrors.InvalidArgument(field + " is required") return merrors.InvalidArgument(field + " is required")
} }
@@ -140,9 +140,57 @@ func validateTransferIntent(intent *transferv1.TransferIntent, field string) err
if intent.GetAmount() == nil { if intent.GetAmount() == nil {
return merrors.InvalidArgument(field + ".amount is required") return merrors.InvalidArgument(field + ".amount is required")
} }
if err := validateSettlementAndFeeTreatment(intent.GetSettlementMode(), intent.GetFeeTreatment(), field); err != nil {
return err
}
return nil 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 { func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool {
if endpoint == nil { if endpoint == nil {
return false return false

View File

@@ -6,10 +6,10 @@ import (
"testing" "testing"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" 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" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" 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" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -19,7 +19,7 @@ func TestValidateQuotePayment_Success(t *testing.T) {
req := &quotationv2.QuotePaymentRequest{ req := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
Intent: validTransferIntent(), Intent: validQuoteIntent(),
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
} }
@@ -61,7 +61,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
name: "idempotency required for non-preview", name: "idempotency required for non-preview",
req: &quotationv2.QuotePaymentRequest{ req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
Intent: validTransferIntent(), Intent: validQuoteIntent(),
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
}, },
@@ -72,7 +72,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
req: &quotationv2.QuotePaymentRequest{ req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
Intent: validTransferIntent(), Intent: validQuoteIntent(),
PreviewOnly: true, PreviewOnly: true,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
}, },
@@ -83,7 +83,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
req: &quotationv2.QuotePaymentRequest{ req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
Intent: validTransferIntent(), Intent: validQuoteIntent(),
PreviewOnly: false, PreviewOnly: false,
}, },
checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) }, checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) },
@@ -93,7 +93,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
req: &quotationv2.QuotePaymentRequest{ req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "bad-org"}, Meta: &sharedv1.RequestMeta{OrganizationRef: "bad-org"},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
Intent: validTransferIntent(), Intent: validQuoteIntent(),
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
}, },
@@ -106,7 +106,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) {
req: &quotationv2.QuotePaymentRequest{ req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
Intent: &transferv1.TransferIntent{ Intent: &quotationv2.QuoteIntent{
Destination: endpointWithMethodRef("pm-dst"), Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, 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") }, checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") },
}, },
{
name: "fee treatment requires settlement mode",
req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intent: &quotationv2.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: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intent: &quotationv2.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 { for _, tc := range testCases {
@@ -132,7 +170,7 @@ func TestValidateQuotePayments_Success(t *testing.T) {
orgID := bson.NewObjectID() orgID := bson.NewObjectID()
req := &quotationv2.QuotePaymentsRequest{ req := &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()},
Intents: []*transferv1.TransferIntent{validTransferIntent(), validTransferIntent()}, Intents: []*quotationv2.QuoteIntent{validQuoteIntent(), validQuoteIntent()},
PreviewOnly: true, PreviewOnly: true,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
} }
@@ -178,7 +216,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) {
name: "idempotency required for non-preview", name: "idempotency required for non-preview",
req: &quotationv2.QuotePaymentsRequest{ req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
Intents: []*transferv1.TransferIntent{validTransferIntent()}, Intents: []*quotationv2.QuoteIntent{validQuoteIntent()},
PreviewOnly: false, PreviewOnly: false,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
}, },
@@ -189,7 +227,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) {
req: &quotationv2.QuotePaymentsRequest{ req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
Intents: []*transferv1.TransferIntent{validTransferIntent()}, Intents: []*quotationv2.QuoteIntent{validQuoteIntent()},
PreviewOnly: true, PreviewOnly: true,
InitiatorRef: "actor-1", InitiatorRef: "actor-1",
}, },
@@ -199,7 +237,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) {
name: "initiator ref required", name: "initiator ref required",
req: &quotationv2.QuotePaymentsRequest{ req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
Intents: []*transferv1.TransferIntent{validTransferIntent()}, Intents: []*quotationv2.QuoteIntent{validQuoteIntent()},
PreviewOnly: true, PreviewOnly: true,
}, },
checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) }, checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) },
@@ -209,7 +247,7 @@ func TestValidateQuotePayments_Rules(t *testing.T) {
req: &quotationv2.QuotePaymentsRequest{ req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
Intents: []*transferv1.TransferIntent{ Intents: []*quotationv2.QuoteIntent{
{ {
Source: endpointWithMethodRef("pm-src"), Source: endpointWithMethodRef("pm-src"),
Destination: endpointWithMethodRef("pm-dst"), Destination: endpointWithMethodRef("pm-dst"),
@@ -234,11 +272,12 @@ func TestValidateQuotePayments_Rules(t *testing.T) {
} }
} }
func validTransferIntent() *transferv1.TransferIntent { func validQuoteIntent() *quotationv2.QuoteIntent {
return &transferv1.TransferIntent{ return &quotationv2.QuoteIntent{
Source: endpointWithMethodRef("pm-src"), Source: endpointWithMethodRef("pm-src"),
Destination: endpointWithMethodRef("pm-dst"), Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
} }
} }

View File

@@ -5,6 +5,7 @@ import (
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
) )
@@ -17,14 +18,16 @@ type QuoteMeta struct {
type CanonicalQuote struct { type CanonicalQuote struct {
QuoteRef string QuoteRef string
DebitAmount *moneyv1.Money TransferPrincipalAmount *moneyv1.Money
CreditAmount *moneyv1.Money DestinationAmount *moneyv1.Money
TotalCost *moneyv1.Money PayerTotalDebitAmount *moneyv1.Money
FeeLines []*feesv1.DerivedPostingLine FeeLines []*feesv1.DerivedPostingLine
FeeRules []*feesv1.AppliedRule FeeRules []*feesv1.AppliedRule
FXQuote *oraclev1.Quote FXQuote *oraclev1.Quote
Route *quotationv2.RouteSpecification Route *quotationv2.RouteSpecification
Conditions *quotationv2.ExecutionConditions Conditions *quotationv2.ExecutionConditions
ResolvedSettlementMode paymentv1.SettlementMode
ResolvedFeeTreatment quotationv2.FeeTreatment
ExpiresAt time.Time ExpiresAt time.Time
PricedAt time.Time PricedAt time.Time
} }

View File

@@ -4,7 +4,9 @@ import (
"strings" "strings"
"time" "time"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
storablev1 "github.com/tech/sendico/pkg/proto/common/storable/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" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -20,22 +22,27 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
expiresAt := normalizeQuoteExpiresAt(in.Quote.ExpiresAt, in.Quote.FXQuote)
settlementMode := normalizeResolvedSettlementMode(in.Quote.ResolvedSettlementMode)
feeTreatment := normalizeResolvedFeeTreatment(settlementMode, in.Quote.ResolvedFeeTreatment)
result := &quotationv2.PaymentQuote{ result := &quotationv2.PaymentQuote{
Storable: mapStorable(in.Meta), Storable: mapStorable(in.Meta),
State: decision.state, State: decision.state,
BlockReason: decision.blockReason, BlockReason: decision.blockReason,
DebitAmount: cloneMoney(in.Quote.DebitAmount), TransferPrincipalAmount: cloneMoney(in.Quote.TransferPrincipalAmount),
CreditAmount: cloneMoney(in.Quote.CreditAmount), DestinationAmount: cloneMoney(in.Quote.DestinationAmount),
TotalCost: cloneMoney(in.Quote.TotalCost), PayerTotalDebitAmount: cloneMoney(in.Quote.PayerTotalDebitAmount),
FeeLines: cloneFeeLines(in.Quote.FeeLines), FeeLines: cloneFeeLines(in.Quote.FeeLines),
FeeRules: cloneFeeRules(in.Quote.FeeRules), FeeRules: cloneFeeRules(in.Quote.FeeRules),
FxQuote: cloneFXQuote(in.Quote.FXQuote), FxQuote: cloneFXQuote(in.Quote.FXQuote),
Route: cloneRoute(in.Quote.Route), Route: cloneRoute(in.Quote.Route),
ExecutionConditions: cloneExecutionConditions(in.Quote.Conditions), ExecutionConditions: cloneExecutionConditions(in.Quote.Conditions),
QuoteRef: strings.TrimSpace(in.Quote.QuoteRef), QuoteRef: strings.TrimSpace(in.Quote.QuoteRef),
ExpiresAt: tsOrNil(in.Quote.ExpiresAt), ExpiresAt: tsOrNil(expiresAt),
PricedAt: tsOrNil(in.Quote.PricedAt), PricedAt: tsOrNil(in.Quote.PricedAt),
ResolvedSettlementMode: settlementMode,
ResolvedFeeTreatment: feeTreatment,
} }
return &MapOutput{ return &MapOutput{
@@ -63,3 +70,50 @@ func tsOrNil(value time.Time) *timestamppb.Timestamp {
} }
return timestamppb.New(value) 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
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/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" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
) )
@@ -26,15 +27,15 @@ func TestMap_ExecutableQuote(t *testing.T) {
}, },
Quote: CanonicalQuote{ Quote: CanonicalQuote{
QuoteRef: "q-1", QuoteRef: "q-1",
DebitAmount: &moneyv1.Money{ TransferPrincipalAmount: &moneyv1.Money{
Amount: "10", Amount: "10",
Currency: "USD", Currency: "USD",
}, },
CreditAmount: &moneyv1.Money{ DestinationAmount: &moneyv1.Money{
Amount: "9", Amount: "9",
Currency: "EUR", Currency: "EUR",
}, },
TotalCost: &moneyv1.Money{ PayerTotalDebitAmount: &moneyv1.Money{
Amount: "10.2", Amount: "10.2",
Currency: "USD", Currency: "USD",
}, },
@@ -57,6 +58,8 @@ func TestMap_ExecutableQuote(t *testing.T) {
PrefundingRequired: false, PrefundingRequired: false,
LiquidityCheckRequiredAtExecution: true, LiquidityCheckRequiredAtExecution: true,
}, },
ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
PricedAt: pricedAt, PricedAt: pricedAt,
}, },
@@ -103,8 +106,68 @@ func TestMap_ExecutableQuote(t *testing.T) {
if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want { if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
t.Fatalf("unexpected settlement token: got=%q want=%q", got, want) t.Fatalf("unexpected settlement token: got=%q want=%q", got, want)
} }
if got, want := out.Quote.GetTotalCost().GetAmount(), "10.2"; got != want { if got, want := out.Quote.GetPayerTotalDebitAmount().GetAmount(), "10.2"; got != want {
t.Fatalf("unexpected total_cost amount: got=%q want=%q", 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())
} }
} }

View File

@@ -7,8 +7,9 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types" 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" 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" "go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
@@ -16,13 +17,13 @@ import (
type HydrateOneInput struct { type HydrateOneInput struct {
OrganizationRef string OrganizationRef string
InitiatorRef string InitiatorRef string
Intent *transferv1.TransferIntent Intent *quotationv2.QuoteIntent
} }
type HydrateManyInput struct { type HydrateManyInput struct {
OrganizationRef string OrganizationRef string
InitiatorRef string InitiatorRef string
Intents []*transferv1.TransferIntent Intents []*quotationv2.QuoteIntent
} }
type PaymentMethodsClient interface { type PaymentMethodsClient interface {
@@ -76,6 +77,24 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
return nil, merrors.InvalidArgument("intent.amount is required") 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( source, err := h.hydrateEndpoint(
ctx, ctx,
in.OrganizationRef, in.OrganizationRef,
@@ -97,16 +116,11 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
return nil, err return nil, err
} }
amount := &paymenttypes.Money{ settlementCurrency := strings.ToUpper(strings.TrimSpace(in.Intent.GetSettlementCurrency()))
Amount: strings.TrimSpace(in.Intent.GetAmount().GetAmount()), if settlementCurrency == "" {
Currency: strings.TrimSpace(in.Intent.GetAmount().GetCurrency()), settlementCurrency = strings.ToUpper(strings.TrimSpace(amount.Currency))
}
if amount.Amount == "" {
return nil, merrors.InvalidArgument("intent.amount.amount is required")
}
if amount.Currency == "" {
return nil, merrors.InvalidArgument("intent.amount.currency is required")
} }
requiresFX := !strings.EqualFold(amount.Currency, settlementCurrency)
intent := &QuoteIntent{ intent := &QuoteIntent{
Ref: h.newRef(), Ref: h.newRef(),
@@ -115,11 +129,14 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
Destination: destination, Destination: destination,
Amount: amount, Amount: amount,
Comment: strings.TrimSpace(in.Intent.GetComment()), Comment: strings.TrimSpace(in.Intent.GetComment()),
SettlementMode: QuoteSettlementModeUnspecified, SettlementMode: settlementMode,
SettlementCurrency: amount.Currency, FeeTreatment: feeTreatment,
RequiresFX: false, SettlementCurrency: settlementCurrency,
RequiresFX: requiresFX,
Attributes: map[string]string{ 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 != "" { if intent.Comment != "" {
@@ -146,3 +163,67 @@ func (h *TransferIntentHydrator) HydrateMany(ctx context.Context, in HydrateMany
} }
return out, nil 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
}
}

View File

@@ -19,6 +19,14 @@ const (
QuoteSettlementModeFixReceived QuoteSettlementMode = "fix_received" QuoteSettlementModeFixReceived QuoteSettlementMode = "fix_received"
) )
type QuoteFeeTreatment string
const (
QuoteFeeTreatmentUnspecified QuoteFeeTreatment = "unspecified"
QuoteFeeTreatmentAddToSource QuoteFeeTreatment = "add_to_source"
QuoteFeeTreatmentDeductFromDestination QuoteFeeTreatment = "deduct_from_destination"
)
type QuoteEndpointType string type QuoteEndpointType string
const ( const (
@@ -75,6 +83,7 @@ type QuoteIntent struct {
Amount *paymenttypes.Money Amount *paymenttypes.Money
Comment string Comment string
SettlementMode QuoteSettlementMode SettlementMode QuoteSettlementMode
FeeTreatment QuoteFeeTreatment
SettlementCurrency string SettlementCurrency string
RequiresFX bool RequiresFX bool
Attributes map[string]string Attributes map[string]string

View File

@@ -8,11 +8,12 @@ import (
pkgmodel "github.com/tech/sendico/pkg/model" pkgmodel "github.com/tech/sendico/pkg/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" 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" pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1"
storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/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" "go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
@@ -20,7 +21,7 @@ import (
func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) { func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) {
h := New(nil, WithRefFactory(func() string { return "q-intent-1" })) h := New(nil, WithRefFactory(func() string { return "q-intent-1" }))
tag := "12345" tag := "12345"
intent := &transferv1.TransferIntent{ intent := &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{
@@ -86,6 +87,12 @@ func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) {
if got.SettlementCurrency != "USDT" { if got.SettlementCurrency != "USDT" {
t.Fatalf("expected settlement currency USDT, got %q", got.SettlementCurrency) 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" { if got.Amount == nil || got.Amount.Amount != "10.25" {
t.Fatalf("unexpected amount: %#v", got.Amount) t.Fatalf("unexpected amount: %#v", got.Amount)
} }
@@ -120,7 +127,7 @@ func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) {
}, },
}) })
intent := &transferv1.TransferIntent{ intent := &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: methodRef}, Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: methodRef},
}, },
@@ -201,7 +208,7 @@ func TestHydrateOne_ResolvesPayeeRefViaPrivateMethod(t *testing.T) {
}, },
}) })
intent := &transferv1.TransferIntent{ intent := &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{
@@ -275,7 +282,7 @@ func TestHydrateOne_ResolvesInlineAccountPaymentMethodViaPrivateMethod(t *testin
}, },
}) })
intent := &transferv1.TransferIntent{ intent := &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{ PaymentMethod: &endpointv1.PaymentMethod{
@@ -327,7 +334,7 @@ func TestHydrateOne_ResolvesInlineAccountPaymentMethodViaPrivateMethod(t *testin
func TestHydrateOne_ReferenceWithoutMethodsClientFails(t *testing.T) { func TestHydrateOne_ReferenceWithoutMethodsClientFails(t *testing.T) {
h := New(nil) h := New(nil)
intent := &transferv1.TransferIntent{ intent := &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: bson.NewObjectID().Hex()}, Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: bson.NewObjectID().Hex()},
}, },
@@ -356,7 +363,7 @@ func TestHydrateOne_ReferenceWithoutMethodsClientFails(t *testing.T) {
func TestHydrateMany_IndexesError(t *testing.T) { func TestHydrateMany_IndexesError(t *testing.T) {
h := New(nil) h := New(nil)
intents := []*transferv1.TransferIntent{ intents := []*quotationv2.QuoteIntent{
{ {
Source: &endpointv1.PaymentEndpoint{ Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{ Source: &endpointv1.PaymentEndpoint_PaymentMethod{
@@ -422,6 +429,26 @@ func TestHydrateMany_IndexesError(t *testing.T) {
} }
} }
func TestHydrateOne_RejectsConflictingEconomics(t *testing.T) {
h := New(nil)
intent := &quotationv2.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 { type fakeMethodsClient struct {
getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error) 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 { func mustMarshalBSON(t *testing.T, value any) []byte {
t.Helper() t.Helper()
data, err := bson.Marshal(value) data, err := bson.Marshal(value)

View File

@@ -4,8 +4,6 @@ package common.payment.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
import "google/protobuf/wrappers.proto";
// ------------------------- // -------------------------
// Card network (payment system) // Card network (payment system)

View File

@@ -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. // SettlementMode defines how to treat fees/FX variance for payouts.
enum SettlementMode { enum SettlementMode {
SETTLEMENT_UNSPECIFIED = 0; SETTLEMENT_UNSPECIFIED = 0;
SETTLEMENT_FIX_SOURCE = 1; // customer pays fees; sent amount fixed SETTLEMENT_FIX_SOURCE = 1;
SETTLEMENT_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes SETTLEMENT_FIX_RECEIVED = 2;
} }

View File

@@ -8,12 +8,12 @@ import "google/protobuf/timestamp.proto";
import "api/proto/common/storable/v1/storable.proto"; import "api/proto/common/storable/v1/storable.proto";
import "api/proto/common/money/v1/money.proto"; import "api/proto/common/money/v1/money.proto";
import "api/proto/common/payment/v1/asset.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/billing/fees/v1/fees.proto";
import "api/proto/oracle/v1/oracle.proto"; import "api/proto/oracle/v1/oracle.proto";
enum QuoteState { enum QuoteState {
QUOTE_STATE_UNSPECIFIED = 0; QUOTE_STATE_UNSPECIFIED = 0;
QUOTE_STATE_INDICATIVE = 1; QUOTE_STATE_INDICATIVE = 1;
QUOTE_STATE_EXECUTABLE = 2; QUOTE_STATE_EXECUTABLE = 2;
QUOTE_STATE_BLOCKED = 3; QUOTE_STATE_BLOCKED = 3;
@@ -22,7 +22,6 @@ enum QuoteState {
enum QuoteBlockReason { enum QuoteBlockReason {
QUOTE_BLOCK_REASON_UNSPECIFIED = 0; QUOTE_BLOCK_REASON_UNSPECIFIED = 0;
QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1; QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1;
QUOTE_BLOCK_REASON_LIMIT_BLOCKED = 2; QUOTE_BLOCK_REASON_LIMIT_BLOCKED = 2;
QUOTE_BLOCK_REASON_RISK_BLOCKED = 3; QUOTE_BLOCK_REASON_RISK_BLOCKED = 3;
@@ -46,6 +45,12 @@ enum RouteHopRole {
ROUTE_HOP_ROLE_DESTINATION = 3; 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 { message RouteHop {
uint32 index = 1; uint32 index = 1;
string rail = 2; string rail = 2;
@@ -67,13 +72,11 @@ message RouteSpecification {
string rail = 1; string rail = 1;
string provider = 2; string provider = 2;
string payout_method = 3; string payout_method = 3;
reserved 4, 5; string network = 4;
reserved "settlement_asset", "settlement_model"; string route_ref = 5;
string network = 6; string pricing_profile_ref = 6;
string route_ref = 7; repeated RouteHop hops = 7;
string pricing_profile_ref = 8; RouteSettlement settlement = 8;
repeated RouteHop hops = 9;
RouteSettlement settlement = 10;
} }
// Execution assumptions and constraints evaluated at quotation time. // Execution assumptions and constraints evaluated at quotation time.
@@ -91,24 +94,27 @@ message ExecutionConditions {
message PaymentQuote { message PaymentQuote {
common.storable.v1.Storable storable = 1; common.storable.v1.Storable storable = 1;
QuoteState state = 2; QuoteState state = 2;
QuoteBlockReason block_reason = 4; QuoteBlockReason block_reason = 3;
reserved 3, 13;
reserved "kind", "lifecycle", "executable";
common.money.v1.Money debit_amount = 5; // Transfer principal amount before fees.
common.money.v1.Money credit_amount = 6; 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.DerivedPostingLine fee_lines = 6;
repeated fees.v1.AppliedRule fee_rules = 8; 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 expires_at = 10;
google.protobuf.Timestamp priced_at = 12; google.protobuf.Timestamp priced_at = 11;
RouteSpecification route = 14; RouteSpecification route = 12;
ExecutionConditions execution_conditions = 15; ExecutionConditions execution_conditions = 13;
common.money.v1.Money total_cost = 16; // 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;
} }

View File

@@ -5,14 +5,25 @@ package payments.quotation.v2;
option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v2;quotationv2"; 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/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"; 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 { message QuotePaymentRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
payments.transfer.v1.TransferIntent intent = 3; payments.quotation.v2.QuoteIntent intent = 3;
bool preview_only = 4; bool preview_only = 4;
string initiator_ref = 5; string initiator_ref = 5;
} }
@@ -25,7 +36,7 @@ message QuotePaymentResponse {
message QuotePaymentsRequest { message QuotePaymentsRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
repeated payments.transfer.v1.TransferIntent intents = 3; repeated payments.quotation.v2.QuoteIntent intents = 3;
bool preview_only = 4; bool preview_only = 4;
string initiator_ref = 5; string initiator_ref = 5;
} }

View File

@@ -16,8 +16,8 @@ replace github.com/tech/sendico/gateway/tron => ../gateway/tron
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.1 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/config v1.32.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.8 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/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2 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/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/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/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/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect

View File

@@ -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 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 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/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.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= 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.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= 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.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= 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 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/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= 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/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 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/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.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
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/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 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/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= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=

View File

@@ -2,6 +2,7 @@ package sresponse
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
@@ -148,8 +149,8 @@ func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
result = append(result, FeeLine{ result = append(result, FeeLine{
LedgerAccountRef: line.GetLedgerAccountRef(), LedgerAccountRef: line.GetLedgerAccountRef(),
Amount: toMoney(line.GetMoney()), Amount: toMoney(line.GetMoney()),
LineType: line.GetLineType().String(), LineType: enumJSONName(line.GetLineType().String()),
Side: line.GetSide().String(), Side: enumJSONName(line.GetSide().String()),
Meta: line.GetMeta(), Meta: line.GetMeta(),
}) })
} }
@@ -178,7 +179,7 @@ func toFxQuote(q *oraclev1.Quote) *FxQuote {
QuoteRef: q.GetQuoteRef(), QuoteRef: q.GetQuoteRef(),
BaseCurrency: base, BaseCurrency: base,
QuoteCurrency: quote, QuoteCurrency: quote,
Side: q.GetSide().String(), Side: enumJSONName(q.GetSide().String()),
Price: q.GetPrice().GetValue(), Price: q.GetPrice().GetValue(),
BaseAmount: toMoney(q.GetBaseAmount()), BaseAmount: toMoney(q.GetBaseAmount()),
QuoteAmount: toMoney(q.GetQuoteAmount()), QuoteAmount: toMoney(q.GetQuoteAmount()),
@@ -260,11 +261,15 @@ func toPayment(p *sharedv1.Payment) *Payment {
return &Payment{ return &Payment{
PaymentRef: p.GetPaymentRef(), PaymentRef: p.GetPaymentRef(),
IdempotencyKey: p.GetIdempotencyKey(), IdempotencyKey: p.GetIdempotencyKey(),
State: p.GetState().String(), State: enumJSONName(p.GetState().String()),
FailureCode: p.GetFailureCode().String(), FailureCode: enumJSONName(p.GetFailureCode().String()),
FailureReason: p.GetFailureReason(), FailureReason: p.GetFailureReason(),
LastQuote: toPaymentQuote(p.GetLastQuote()), LastQuote: toPaymentQuote(p.GetLastQuote()),
CreatedAt: p.GetCreatedAt().AsTime(), CreatedAt: p.GetCreatedAt().AsTime(),
Meta: p.GetMetadata(), Meta: p.GetMetadata(),
} }
} }
func enumJSONName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}