quotation v2 service #529
4
Makefile
4
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,21 +74,36 @@ 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),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func modelMoneyFromProto(src *moneyv1.Money) *paymenttypes.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -135,17 +135,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),
|
||||
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{
|
||||
Quote: canonical,
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -43,6 +44,8 @@ type ComputedQuote struct {
|
||||
FXQuote *oraclev1.Quote
|
||||
Route *quotationv2.RouteSpecification
|
||||
ExecutionConditions *quotationv2.ExecutionConditions
|
||||
ResolvedSettlementMode paymentv1.SettlementMode
|
||||
ResolvedFeeTreatment quotationv2.FeeTreatment
|
||||
}
|
||||
|
||||
// QuoteComputationPlan is an orchestration plan for quote computations.
|
||||
@@ -67,6 +70,8 @@ type QuoteComputationPlanItem struct {
|
||||
Funding *gateway_funding_profile.QuoteFundingGate
|
||||
Route *quotationv2.RouteSpecification
|
||||
ExecutionConditions *quotationv2.ExecutionConditions
|
||||
ResolvedSettlementMode paymentv1.SettlementMode
|
||||
ResolvedFeeTreatment quotationv2.FeeTreatment
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
@@ -168,6 +170,8 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
Funding: funding,
|
||||
Route: route,
|
||||
ExecutionConditions: conditions,
|
||||
ResolvedSettlementMode: resolvedSettlementMode,
|
||||
ResolvedFeeTreatment: resolvedFeeTreatment,
|
||||
BlockReason: blockReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -17,14 +18,16 @@ type QuoteMeta struct {
|
||||
|
||||
type CanonicalQuote struct {
|
||||
QuoteRef string
|
||||
DebitAmount *moneyv1.Money
|
||||
CreditAmount *moneyv1.Money
|
||||
TotalCost *moneyv1.Money
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
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(in.Quote.ExpiresAt),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +58,8 @@ func TestMap_ExecutableQuote(t *testing.T) {
|
||||
PrefundingRequired: false,
|
||||
LiquidityCheckRequiredAtExecution: true,
|
||||
},
|
||||
ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE,
|
||||
ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE,
|
||||
ExpiresAt: expiresAt,
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user