outbox for gateways

This commit is contained in:
Stephan D
2026-02-18 01:35:28 +01:00
parent 974caf286c
commit 69531cee73
221 changed files with 12172 additions and 782 deletions

View File

@@ -63,5 +63,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
)

View File

@@ -211,8 +211,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -46,7 +46,7 @@ func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quot
logger.Warn("Failed to build fx conversion plan", zap.Error(err))
return nil, err
}
logger.Info("fx conversion plan built", zap.Int("steps", len(plan.Steps)))
logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps)))
return plan, nil
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/shared"
"github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mutil/mzap"
@@ -105,7 +105,7 @@ func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *mod
action, err := actionForOperation(tpl.Operation)
if err != nil {
b.logger.Warn("plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err))
b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err))
return nil, err
}

View File

@@ -0,0 +1,20 @@
package batch_quote_processor_v2
import (
"fmt"
"strings"
)
func deriveItemIdempotencyKey(base string, previewOnly bool, index int, total int) string {
if previewOnly {
return ""
}
base = strings.TrimSpace(base)
if base == "" {
return ""
}
if total <= 1 {
return base
}
return fmt.Sprintf("%s:%d", base, index+1)
}

View File

@@ -0,0 +1,53 @@
package batch_quote_processor_v2
import (
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
// BatchContext carries normalized request-level parameters for batch processing.
type BatchContext struct {
OrganizationRef string
OrganizationID bson.ObjectID
InitiatorRef string
PreviewOnly bool
BaseIdempotencyKey string
}
// ProcessInput is the input contract for batch quote processing.
type ProcessInput struct {
Context BatchContext
Intents []*transfer_intent_hydrator.QuoteIntent
}
// BatchItem is one derived processing unit for a single intent.
type BatchItem struct {
Index int
Count int
IdempotencyKey string
Intent *transfer_intent_hydrator.QuoteIntent
}
// SingleProcessInput is forwarded to the single-intent processor.
type SingleProcessInput struct {
Context BatchContext
Item BatchItem
}
// SingleProcessOutput is returned by the single-intent processor.
type SingleProcessOutput struct {
Quote *quotationv2.PaymentQuote
}
// BatchItemResult is one successful item output.
type BatchItemResult struct {
Item BatchItem
Quote *quotationv2.PaymentQuote
}
// ProcessOutput is an ordered list of item outputs.
type ProcessOutput struct {
Context BatchContext
Items []*BatchItemResult
}

View File

@@ -0,0 +1,111 @@
package batch_quote_processor_v2
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/pkg/merrors"
)
// SingleIntentProcessor processes one intent in v2 flow.
type SingleIntentProcessor interface {
Process(ctx context.Context, in SingleProcessInput) (*SingleProcessOutput, error)
}
// BatchQuoteProcessorV2 iterates single-intent processing for batch requests.
type BatchQuoteProcessorV2 struct {
single SingleIntentProcessor
}
func New(single SingleIntentProcessor) *BatchQuoteProcessorV2 {
return &BatchQuoteProcessorV2{single: single}
}
func (p *BatchQuoteProcessorV2) Process(ctx context.Context, in ProcessInput) (*ProcessOutput, error) {
if p == nil || p.single == nil {
return nil, merrors.InvalidArgument("single processor is required")
}
normalizedCtx, err := normalizeContext(in.Context)
if err != nil {
return nil, err
}
if len(in.Intents) == 0 {
return nil, merrors.InvalidArgument("intents are required")
}
items, err := buildBatchItems(normalizedCtx, in.Intents)
if err != nil {
return nil, err
}
results := make([]*BatchItemResult, 0, len(items))
for _, item := range items {
res, processErr := p.single.Process(ctx, SingleProcessInput{
Context: normalizedCtx,
Item: *item,
})
if processErr != nil {
return nil, fmt.Errorf("intents[%d]: %w", item.Index, processErr)
}
if res == nil || res.Quote == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("intents[%d]: quote is required", item.Index))
}
results = append(results, &BatchItemResult{
Item: *item,
Quote: res.Quote,
})
}
return &ProcessOutput{
Context: normalizedCtx,
Items: results,
}, nil
}
func normalizeContext(ctx BatchContext) (BatchContext, error) {
ctx.OrganizationRef = strings.TrimSpace(ctx.OrganizationRef)
ctx.InitiatorRef = strings.TrimSpace(ctx.InitiatorRef)
ctx.BaseIdempotencyKey = strings.TrimSpace(ctx.BaseIdempotencyKey)
if ctx.OrganizationRef == "" {
return BatchContext{}, merrors.InvalidArgument("organization_ref is required")
}
if ctx.OrganizationID.IsZero() {
return BatchContext{}, merrors.InvalidArgument("organization_id is required")
}
if ctx.InitiatorRef == "" {
return BatchContext{}, merrors.InvalidArgument("initiator_ref is required")
}
if ctx.PreviewOnly && ctx.BaseIdempotencyKey != "" {
return BatchContext{}, merrors.InvalidArgument("preview requests must not use idempotency key")
}
if !ctx.PreviewOnly && ctx.BaseIdempotencyKey == "" {
return BatchContext{}, merrors.InvalidArgument("idempotency key is required")
}
return ctx, nil
}
func buildBatchItems(ctx BatchContext, intents []*transfer_intent_hydrator.QuoteIntent) ([]*BatchItem, error) {
items := make([]*BatchItem, 0, len(intents))
total := len(intents)
for i, intent := range intents {
if intent == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("intents[%d] is required", i))
}
items = append(items, &BatchItem{
Index: i,
Count: total,
Intent: intent,
IdempotencyKey: deriveItemIdempotencyKey(ctx.BaseIdempotencyKey, ctx.PreviewOnly, i, total),
})
}
return items, nil
}

View File

@@ -0,0 +1,201 @@
package batch_quote_processor_v2
import (
"context"
"errors"
"strings"
"testing"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestProcess_DerivesPerItemIdempotency(t *testing.T) {
processor := &fakeSingleProcessor{}
svc := New(processor)
orgID := bson.NewObjectID()
out, err := svc.Process(context.Background(), ProcessInput{
Context: BatchContext{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
InitiatorRef: "initiator-1",
PreviewOnly: false,
BaseIdempotencyKey: "idem-batch",
},
Intents: []*transfer_intent_hydrator.QuoteIntent{
{Ref: "i1"},
{Ref: "i2"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil || len(out.Items) != 2 {
t.Fatalf("expected two items")
}
if got, want := processor.calls[0].Item.IdempotencyKey, "idem-batch:1"; got != want {
t.Fatalf("unexpected first item key: got=%q want=%q", got, want)
}
if got, want := processor.calls[1].Item.IdempotencyKey, "idem-batch:2"; got != want {
t.Fatalf("unexpected second item key: got=%q want=%q", got, want)
}
if out.Items[0].Item.Index != 0 || out.Items[1].Item.Index != 1 {
t.Fatalf("expected stable ordered indices")
}
}
func TestProcess_SingleIntentKeepsBaseIdempotency(t *testing.T) {
processor := &fakeSingleProcessor{}
svc := New(processor)
orgID := bson.NewObjectID()
_, err := svc.Process(context.Background(), ProcessInput{
Context: BatchContext{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
InitiatorRef: "initiator-1",
PreviewOnly: false,
BaseIdempotencyKey: "idem-single",
},
Intents: []*transfer_intent_hydrator.QuoteIntent{
{Ref: "i1"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := processor.calls[0].Item.IdempotencyKey, "idem-single"; got != want {
t.Fatalf("unexpected idempotency key: got=%q want=%q", got, want)
}
}
func TestProcess_PreviewUsesEmptyPerItemIdempotency(t *testing.T) {
processor := &fakeSingleProcessor{}
svc := New(processor)
orgID := bson.NewObjectID()
_, err := svc.Process(context.Background(), ProcessInput{
Context: BatchContext{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
InitiatorRef: "initiator-1",
PreviewOnly: true,
},
Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}, {Ref: "i2"}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if processor.calls[0].Item.IdempotencyKey != "" || processor.calls[1].Item.IdempotencyKey != "" {
t.Fatalf("expected empty idempotency keys for preview")
}
}
func TestProcess_WrapsItemError(t *testing.T) {
processor := &fakeSingleProcessor{
errAtIndex: 1,
err: errors.New("boom"),
}
svc := New(processor)
orgID := bson.NewObjectID()
_, err := svc.Process(context.Background(), ProcessInput{
Context: BatchContext{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
InitiatorRef: "initiator-1",
PreviewOnly: false,
BaseIdempotencyKey: "idem",
},
Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}, {Ref: "i2"}},
})
if err == nil {
t.Fatalf("expected wrapped error")
}
if !strings.Contains(err.Error(), "intents[1]") {
t.Fatalf("expected indexed error, got %v", err)
}
}
func TestProcess_Validation(t *testing.T) {
orgID := bson.NewObjectID()
baseInput := ProcessInput{
Context: BatchContext{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
InitiatorRef: "initiator-1",
PreviewOnly: false,
BaseIdempotencyKey: "idem",
},
Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}},
}
_, err := New(nil).Process(context.Background(), baseInput)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for nil single processor, got %v", err)
}
previewWithIdem := baseInput
previewWithIdem.Context.PreviewOnly = true
_, err = New(&fakeSingleProcessor{}).Process(context.Background(), previewWithIdem)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for preview idempotency, got %v", err)
}
nonPreviewNoIdem := baseInput
nonPreviewNoIdem.Context.BaseIdempotencyKey = ""
_, err = New(&fakeSingleProcessor{}).Process(context.Background(), nonPreviewNoIdem)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for missing idempotency, got %v", err)
}
withNilIntent := baseInput
withNilIntent.Intents = []*transfer_intent_hydrator.QuoteIntent{nil}
_, err = New(&fakeSingleProcessor{}).Process(context.Background(), withNilIntent)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for nil intent, got %v", err)
}
}
func TestProcess_RejectsNilQuoteOutput(t *testing.T) {
svc := New(&fakeSingleProcessor{returnNilQuote: true})
orgID := bson.NewObjectID()
_, err := svc.Process(context.Background(), ProcessInput{
Context: BatchContext{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
InitiatorRef: "initiator-1",
PreviewOnly: false,
BaseIdempotencyKey: "idem",
},
Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for nil quote output, got %v", err)
}
}
type fakeSingleProcessor struct {
calls []SingleProcessInput
errAtIndex int
err error
returnNilQuote bool
}
func (f *fakeSingleProcessor) Process(_ context.Context, in SingleProcessInput) (*SingleProcessOutput, error) {
f.calls = append(f.calls, in)
if f.err != nil && in.Item.Index == f.errAtIndex {
return nil, f.err
}
if f.returnNilQuote {
return &SingleProcessOutput{}, nil
}
return &SingleProcessOutput{
Quote: &quotationv2.PaymentQuote{QuoteRef: in.Item.Intent.Ref},
}, nil
}

View File

@@ -0,0 +1,181 @@
package gateway_funding_profile
import (
"strings"
"github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func BuildFundingGateFromProfile(
profile *GatewayFundingProfile,
requiredAmount *moneyv1.Money,
) (*QuoteFundingGate, error) {
if profile == nil {
return nil, nil
}
mode := shared.NormalizeFundingMode(profile.Mode)
gate := &QuoteFundingGate{
Mode: mode,
RequiredAmount: cloneProtoMoney(requiredAmount),
Funding: clonePaymentEndpoint(profile.FundingDestination),
Fee: clonePaymentEndpoint(profile.FeeDestination),
Reserve: cloneReservePolicy(profile.Reserve),
DepositCheck: cloneDepositCheck(profile.DepositCheck),
}
switch mode {
case model.FundingModeNone:
return gate, nil
case model.FundingModeBalanceReserve:
if gate.Reserve == nil {
return nil, merrors.InvalidArgument("funding profile: reserve policy is required for balance_reserve")
}
hasBlock := strings.TrimSpace(gate.Reserve.BlockAccountRef) != ""
hasRoles := gate.Reserve.FromRole != nil && gate.Reserve.ToRole != nil
if !hasBlock && !hasRoles {
return nil, merrors.InvalidArgument("funding profile: block account or reserve roles are required for balance_reserve")
}
if gate.RequiredAmount == nil {
return nil, merrors.InvalidArgument("funding profile: required amount is required for balance_reserve")
}
return gate, nil
case model.FundingModeDepositObserved:
if gate.DepositCheck == nil {
return nil, merrors.InvalidArgument("funding profile: deposit check policy is required for deposit_observed")
}
if strings.TrimSpace(gate.DepositCheck.WalletRef) == "" {
return nil, merrors.InvalidArgument("funding profile: deposit wallet_ref is required for deposit_observed")
}
if gate.DepositCheck.ExpectedAmount == nil {
gate.DepositCheck.ExpectedAmount = cloneProtoMoney(requiredAmount)
}
if gate.DepositCheck.ExpectedAmount == nil {
return nil, merrors.InvalidArgument("funding profile: deposit expected_amount is required for deposit_observed")
}
return gate, nil
default:
return nil, merrors.InvalidArgument("funding profile: mode is required")
}
}
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.TrimSpace(src.GetCurrency()),
}
}
func clonePaymentEndpoint(src *model.PaymentEndpoint) *model.PaymentEndpoint {
if src == nil {
return nil
}
result := &model.PaymentEndpoint{
Type: src.Type,
InstanceID: strings.TrimSpace(src.InstanceID),
}
if src.Ledger != nil {
result.Ledger = &model.LedgerEndpoint{
LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef),
ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef),
}
}
if src.ManagedWallet != nil {
result.ManagedWallet = &model.ManagedWalletEndpoint{
ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef),
Asset: cloneAsset(src.ManagedWallet.Asset),
}
}
if src.ExternalChain != nil {
result.ExternalChain = &model.ExternalChainEndpoint{
Asset: cloneAsset(src.ExternalChain.Asset),
Address: strings.TrimSpace(src.ExternalChain.Address),
Memo: strings.TrimSpace(src.ExternalChain.Memo),
}
}
if src.Card != nil {
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(src.Card.Pan),
Token: strings.TrimSpace(src.Card.Token),
Cardholder: strings.TrimSpace(src.Card.Cardholder),
CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname),
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: strings.TrimSpace(src.Card.Country),
MaskedPan: strings.TrimSpace(src.Card.MaskedPan),
}
}
return result
}
func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset {
if src == nil {
return nil
}
return &paymenttypes.Asset{
Chain: strings.TrimSpace(src.Chain),
TokenSymbol: strings.TrimSpace(src.TokenSymbol),
ContractAddress: strings.TrimSpace(src.ContractAddress),
}
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
out := make(map[string]string, len(src))
for k, v := range src {
kk := strings.TrimSpace(k)
if kk == "" {
continue
}
out[kk] = strings.TrimSpace(v)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneReservePolicy(src *ReservePolicy) *ReservePolicy {
if src == nil {
return nil
}
result := &ReservePolicy{
DebitAccountRef: strings.TrimSpace(src.DebitAccountRef),
BlockAccountRef: strings.TrimSpace(src.BlockAccountRef),
ReleaseOnFail: src.ReleaseOnFail,
ReleaseOnCancel: src.ReleaseOnCancel,
}
if src.FromRole != nil {
role := *src.FromRole
result.FromRole = &role
}
if src.ToRole != nil {
role := *src.ToRole
result.ToRole = &role
}
return result
}
func cloneDepositCheck(src *model.DepositCheckPolicy) *model.DepositCheckPolicy {
if src == nil {
return nil
}
return &model.DepositCheckPolicy{
WalletRef: strings.TrimSpace(src.WalletRef),
ExpectedAmount: cloneProtoMoney(src.ExpectedAmount),
MinConfirmations: src.MinConfirmations,
TimeoutSeconds: src.TimeoutSeconds,
}
}

View File

@@ -0,0 +1,127 @@
package gateway_funding_profile
import (
"errors"
"testing"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func TestBuildFundingGateFromProfile_NilProfile(t *testing.T) {
gate, err := BuildFundingGateFromProfile(nil, &moneyv1.Money{
Amount: "100",
Currency: "USD",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gate != nil {
t.Fatalf("expected nil gate")
}
}
func TestBuildFundingGateFromProfile_None(t *testing.T) {
gate, err := BuildFundingGateFromProfile(&GatewayFundingProfile{
Mode: model.FundingModeNone,
}, &moneyv1.Money{
Amount: "100",
Currency: "USD",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gate == nil {
t.Fatalf("expected gate")
}
if gate.Mode != model.FundingModeNone {
t.Fatalf("expected mode %q, got %q", model.FundingModeNone, gate.Mode)
}
}
func TestBuildFundingGateFromProfile_BalanceReserve(t *testing.T) {
from := account_role.AccountRoleOperating
to := account_role.AccountRoleHold
gate, err := BuildFundingGateFromProfile(&GatewayFundingProfile{
Mode: model.FundingModeBalanceReserve,
Reserve: &ReservePolicy{
BlockAccountRef: "ledger:block",
FromRole: &from,
ToRole: &to,
ReleaseOnFail: true,
ReleaseOnCancel: true,
},
}, &moneyv1.Money{
Amount: "100",
Currency: "USD",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gate == nil || gate.Reserve == nil {
t.Fatalf("expected reserve gate")
}
if gate.RequiredAmount == nil || gate.RequiredAmount.GetAmount() != "100" {
t.Fatalf("expected required amount 100, got %#v", gate.RequiredAmount)
}
}
func TestBuildFundingGateFromProfile_BalanceReserveMissingPolicy(t *testing.T) {
_, err := BuildFundingGateFromProfile(&GatewayFundingProfile{
Mode: model.FundingModeBalanceReserve,
}, &moneyv1.Money{
Amount: "100",
Currency: "USD",
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg error, got %v", err)
}
}
func TestBuildFundingGateFromProfile_DepositObservedUsesRequiredAmountFallback(t *testing.T) {
gate, err := BuildFundingGateFromProfile(&GatewayFundingProfile{
Mode: model.FundingModeDepositObserved,
DepositCheck: &model.DepositCheckPolicy{
WalletRef: "wallet-1",
},
}, &moneyv1.Money{
Amount: "42",
Currency: "USDT",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gate == nil || gate.DepositCheck == nil {
t.Fatalf("expected deposit gate")
}
if gate.DepositCheck.ExpectedAmount == nil {
t.Fatalf("expected fallback expected amount")
}
if got := gate.DepositCheck.ExpectedAmount.GetAmount(); got != "42" {
t.Fatalf("expected amount 42, got %q", got)
}
}
func TestBuildFundingGateFromProfile_DepositObservedMissingWallet(t *testing.T) {
_, err := BuildFundingGateFromProfile(&GatewayFundingProfile{
Mode: model.FundingModeDepositObserved,
DepositCheck: &model.DepositCheckPolicy{
ExpectedAmount: &moneyv1.Money{
Amount: "42",
Currency: "USDT",
},
},
}, nil)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg error, got %v", err)
}
}
func TestBuildFundingGateFromProfile_UnspecifiedMode(t *testing.T) {
_, err := BuildFundingGateFromProfile(&GatewayFundingProfile{}, nil)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg error, got %v", err)
}
}

View File

@@ -0,0 +1,63 @@
package gateway_funding_profile
import (
"context"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/model/account_role"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
// ReservePolicy defines where funds are blocked/released for pre-funding.
type ReservePolicy struct {
DebitAccountRef string
BlockAccountRef string
FromRole *account_role.AccountRole
ToRole *account_role.AccountRole
ReleaseOnFail bool
ReleaseOnCancel bool
}
// GatewayFundingProfile is the single source for funding/block resolution.
type GatewayFundingProfile struct {
GatewayID string
InstanceID string
Rail model.Rail
Network string
Currency string
Mode model.FundingMode
FundingDestination *model.PaymentEndpoint
FeeDestination *model.PaymentEndpoint
Reserve *ReservePolicy
DepositCheck *model.DepositCheckPolicy
Defaults map[string]string
}
// QuoteFundingGate describes pre-funding requirements before payout execution.
type QuoteFundingGate struct {
Mode model.FundingMode
RequiredAmount *moneyv1.Money
Funding *model.PaymentEndpoint
Fee *model.PaymentEndpoint
Reserve *ReservePolicy
DepositCheck *model.DepositCheckPolicy
}
type FundingProfileRequest struct {
OrganizationRef string
GatewayID string
InstanceID string
Rail model.Rail
Network string
Currency string
Amount *moneyv1.Money
Source *model.PaymentEndpoint
Destination *model.PaymentEndpoint
Attributes map[string]string
}
type FundingProfileResolver interface {
ResolveGatewayFundingProfile(ctx context.Context, req FundingProfileRequest) (*GatewayFundingProfile, error)
}

View File

@@ -0,0 +1,366 @@
package gateway_funding_profile
import (
"context"
"strings"
"github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/model/account_role"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
const (
defaultCardGatewayKey = "monetix"
)
type CardGatewayFundingRoute struct {
FundingAddress string
FeeAddress string
FeeWalletRef string
}
type StaticFundingProfileResolverInput struct {
DefaultCardGateway string
DefaultMode model.FundingMode
GatewayModes map[string]model.FundingMode
CardRoutes map[string]CardGatewayFundingRoute
FeeLedgerAccounts map[string]string
Profiles map[string]*GatewayFundingProfile
}
type StaticFundingProfileResolver struct {
defaultCardGateway string
defaultMode model.FundingMode
gatewayModes map[string]model.FundingMode
cardRoutes map[string]CardGatewayFundingRoute
feeLedgerAccounts map[string]string
profiles map[string]*GatewayFundingProfile
}
func NewStaticFundingProfileResolver(in StaticFundingProfileResolverInput) *StaticFundingProfileResolver {
r := &StaticFundingProfileResolver{
defaultCardGateway: normalizeGatewayKey(in.DefaultCardGateway),
defaultMode: shared.NormalizeFundingMode(in.DefaultMode),
gatewayModes: map[string]model.FundingMode{},
cardRoutes: map[string]CardGatewayFundingRoute{},
feeLedgerAccounts: map[string]string{},
profiles: map[string]*GatewayFundingProfile{},
}
if r.defaultCardGateway == "" {
r.defaultCardGateway = defaultCardGatewayKey
}
if r.defaultMode == model.FundingModeUnspecified {
r.defaultMode = model.FundingModeNone
}
for k, v := range in.GatewayModes {
if key := normalizeGatewayKey(k); key != "" {
r.gatewayModes[key] = shared.NormalizeFundingMode(v)
}
}
for k, v := range in.CardRoutes {
if key := normalizeGatewayKey(k); key != "" {
r.cardRoutes[key] = CardGatewayFundingRoute{
FundingAddress: strings.TrimSpace(v.FundingAddress),
FeeAddress: strings.TrimSpace(v.FeeAddress),
FeeWalletRef: strings.TrimSpace(v.FeeWalletRef),
}
}
}
for k, v := range in.FeeLedgerAccounts {
if key := normalizeGatewayKey(k); key != "" {
if val := strings.TrimSpace(v); val != "" {
r.feeLedgerAccounts[key] = val
}
}
}
for k, v := range in.Profiles {
if key := normalizeGatewayKey(k); key != "" && v != nil {
r.profiles[key] = cloneGatewayFundingProfile(v)
}
}
return r
}
func (r *StaticFundingProfileResolver) ResolveGatewayFundingProfile(
_ context.Context,
req FundingProfileRequest,
) (*GatewayFundingProfile, error) {
if r == nil {
return nil, nil
}
gatewayKey := r.gatewayKey(req)
attrs := cloneStringMap(req.Attributes)
if attrs == nil {
attrs = map[string]string{}
}
modeSet := false
mode := model.FundingModeUnspecified
var profile *GatewayFundingProfile
if gatewayKey != "" {
if configured, ok := r.profiles[gatewayKey]; ok && configured != nil {
profile = cloneGatewayFundingProfile(configured)
mode = shared.NormalizeFundingMode(profile.Mode)
modeSet = mode != model.FundingModeUnspecified
}
}
if profile == nil {
profile = &GatewayFundingProfile{}
}
profile.GatewayID = firstNonEmpty(
strings.TrimSpace(profile.GatewayID),
gatewayKey,
)
profile.InstanceID = firstNonEmpty(
strings.TrimSpace(profile.InstanceID),
strings.TrimSpace(req.InstanceID),
)
if profile.Rail == model.RailUnspecified {
profile.Rail = req.Rail
}
if strings.TrimSpace(profile.Network) == "" {
profile.Network = strings.TrimSpace(req.Network)
}
if strings.TrimSpace(profile.Currency) == "" {
profile.Currency = normalizedCurrency(req.Currency, req.Amount)
}
if !modeSet && gatewayKey != "" {
if configured, ok := r.gatewayModes[gatewayKey]; ok {
mode = shared.NormalizeFundingMode(configured)
modeSet = mode != model.FundingModeUnspecified
}
}
if !modeSet {
mode = r.defaultMode
}
profile.Mode = mode
sourceAsset := sourceAsset(req.Source)
if gatewayKey != "" {
if route, ok := r.cardRoutes[gatewayKey]; ok {
if profile.FundingDestination == nil && route.FundingAddress != "" {
profile.FundingDestination = &model.PaymentEndpoint{
Type: model.EndpointTypeExternalChain,
ExternalChain: &model.ExternalChainEndpoint{
Address: route.FundingAddress,
Asset: cloneAsset(sourceAsset),
},
}
}
if profile.FeeDestination == nil {
switch {
case route.FeeWalletRef != "":
profile.FeeDestination = &model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: route.FeeWalletRef,
Asset: cloneAsset(sourceAsset),
},
}
case route.FeeAddress != "":
profile.FeeDestination = &model.PaymentEndpoint{
Type: model.EndpointTypeExternalChain,
ExternalChain: &model.ExternalChainEndpoint{
Address: route.FeeAddress,
Asset: cloneAsset(sourceAsset),
},
}
}
}
}
}
if feeAccount := r.feeLedgerAccounts[gatewayKey]; feeAccount != "" {
if profile.Defaults == nil {
profile.Defaults = map[string]string{}
}
if strings.TrimSpace(profile.Defaults["fee_ledger_account_ref"]) == "" {
profile.Defaults["fee_ledger_account_ref"] = feeAccount
}
}
if len(attrs) > 0 {
if profile.Defaults == nil {
profile.Defaults = map[string]string{}
}
if _, ok := profile.Defaults["gateway"]; !ok && gatewayKey != "" {
profile.Defaults["gateway"] = gatewayKey
}
}
if profile.Reserve == nil {
if reserve := reservePolicyFromRequest(req); reserve != nil {
profile.Reserve = reserve
}
}
if profile.Mode == model.FundingModeNone && profile.Reserve != nil {
profile.Mode = model.FundingModeBalanceReserve
}
if isEmptyFundingProfile(profile) {
return nil, nil
}
return profile, nil
}
func (r *StaticFundingProfileResolver) gatewayKey(req FundingProfileRequest) string {
if key := normalizeGatewayKey(req.GatewayID); key != "" {
return key
}
if key := normalizeGatewayKey(req.Attributes["gateway"]); key != "" {
return key
}
if req.Destination != nil && req.Destination.Card != nil {
return r.defaultCardGateway
}
return ""
}
func normalizeGatewayKey(key string) string {
return strings.ToLower(strings.TrimSpace(key))
}
func normalizedCurrency(currency string, amount *moneyv1.Money) string {
if cur := strings.ToUpper(strings.TrimSpace(currency)); cur != "" {
return cur
}
if amount != nil {
return strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
}
return ""
}
func sourceAsset(source *model.PaymentEndpoint) *paymenttypes.Asset {
if source == nil {
return nil
}
if source.ManagedWallet != nil {
return source.ManagedWallet.Asset
}
if source.ExternalChain != nil {
return source.ExternalChain.Asset
}
return nil
}
func reservePolicyFromRequest(req FundingProfileRequest) *ReservePolicy {
if req.Source == nil && len(req.Attributes) == 0 {
return nil
}
reserve := &ReservePolicy{
DebitAccountRef: strings.TrimSpace(lookupAttr(req.Attributes,
"ledger_debit_account_ref",
"ledgerDebitAccountRef",
)),
BlockAccountRef: strings.TrimSpace(lookupAttr(req.Attributes,
"ledger_block_account_ref",
"ledgerBlockAccountRef",
"ledger_hold_account_ref",
"ledgerHoldAccountRef",
"ledger_debit_contra_account_ref",
"ledgerDebitContraAccountRef",
)),
ReleaseOnFail: true,
ReleaseOnCancel: true,
}
if req.Source != nil && req.Source.Ledger != nil {
if reserve.DebitAccountRef == "" {
reserve.DebitAccountRef = strings.TrimSpace(req.Source.Ledger.LedgerAccountRef)
}
if reserve.BlockAccountRef == "" {
reserve.BlockAccountRef = strings.TrimSpace(req.Source.Ledger.ContraLedgerAccountRef)
}
}
if role, ok := parseAccountRole(lookupAttr(req.Attributes, "from_role", "fromRole")); ok {
reserve.FromRole = &role
}
if role, ok := parseAccountRole(lookupAttr(req.Attributes, "to_role", "toRole")); ok {
reserve.ToRole = &role
}
if reserve.DebitAccountRef == "" &&
reserve.BlockAccountRef == "" &&
reserve.FromRole == nil &&
reserve.ToRole == nil {
return nil
}
return reserve
}
func parseAccountRole(value string) (account_role.AccountRole, bool) {
role, ok := account_role.Parse(strings.TrimSpace(value))
if !ok || role == "" {
return "", false
}
return role, true
}
func lookupAttr(attrs map[string]string, keys ...string) string {
if len(attrs) == 0 {
return ""
}
for _, key := range keys {
if key == "" {
continue
}
if val := strings.TrimSpace(attrs[key]); val != "" {
return val
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func cloneGatewayFundingProfile(src *GatewayFundingProfile) *GatewayFundingProfile {
if src == nil {
return nil
}
return &GatewayFundingProfile{
GatewayID: strings.TrimSpace(src.GatewayID),
InstanceID: strings.TrimSpace(src.InstanceID),
Rail: src.Rail,
Network: strings.TrimSpace(src.Network),
Currency: strings.ToUpper(strings.TrimSpace(src.Currency)),
Mode: shared.NormalizeFundingMode(src.Mode),
FundingDestination: clonePaymentEndpoint(src.FundingDestination),
FeeDestination: clonePaymentEndpoint(src.FeeDestination),
Reserve: cloneReservePolicy(src.Reserve),
DepositCheck: cloneDepositCheck(src.DepositCheck),
Defaults: cloneStringMap(src.Defaults),
}
}
func isEmptyFundingProfile(profile *GatewayFundingProfile) bool {
if profile == nil {
return true
}
if profile.FundingDestination != nil || profile.FeeDestination != nil || profile.Reserve != nil || profile.DepositCheck != nil {
return false
}
if profile.Mode != model.FundingModeUnspecified && profile.Mode != model.FundingModeNone {
return false
}
if len(profile.Defaults) > 0 {
return false
}
if profile.GatewayID != "" {
return false
}
return true
}

View File

@@ -0,0 +1,188 @@
package gateway_funding_profile
import (
"context"
"testing"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/model/account_role"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) {
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
DefaultMode: model.FundingModeNone,
CardRoutes: map[string]CardGatewayFundingRoute{
"monetix": {
FundingAddress: "T-FUNDING",
FeeWalletRef: "wallet-fee",
},
},
FeeLedgerAccounts: map[string]string{
"monetix": "ledger:fees",
},
})
profile, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
OrganizationRef: "org-1",
Amount: &moneyv1.Money{
Amount: "100",
Currency: "USDT",
},
Source: &model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
Asset: &paymenttypes.Asset{
Chain: "TRON_MAINNET",
TokenSymbol: "USDT",
},
},
},
Destination: &model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
MaskedPan: "****",
},
},
Attributes: map[string]string{
"initiator_ref": "usr-1",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if profile == nil {
t.Fatalf("expected profile")
}
if profile.GatewayID != "monetix" {
t.Fatalf("expected gateway monetix, got %q", profile.GatewayID)
}
if profile.Mode != model.FundingModeNone {
t.Fatalf("expected mode none, got %q", profile.Mode)
}
if profile.FundingDestination == nil || profile.FundingDestination.ExternalChain == nil {
t.Fatalf("expected funding destination")
}
if got := profile.FundingDestination.ExternalChain.Address; got != "T-FUNDING" {
t.Fatalf("expected funding address T-FUNDING, got %q", got)
}
if profile.FeeDestination == nil || profile.FeeDestination.ManagedWallet == nil {
t.Fatalf("expected managed wallet fee destination")
}
if got := profile.FeeDestination.ManagedWallet.ManagedWalletRef; got != "wallet-fee" {
t.Fatalf("expected fee wallet wallet-fee, got %q", got)
}
if got := profile.Defaults["fee_ledger_account_ref"]; got != "ledger:fees" {
t.Fatalf("expected fee ledger account mapping, got %q", got)
}
}
func TestStaticFundingProfileResolver_ReserveFromSourceAndAttrs(t *testing.T) {
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
GatewayModes: map[string]model.FundingMode{
"mntx": model.FundingModeBalanceReserve,
},
})
profile, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
GatewayID: "mntx",
Source: &model.PaymentEndpoint{
Type: model.EndpointTypeLedger,
Ledger: &model.LedgerEndpoint{
LedgerAccountRef: "ledger:operating",
ContraLedgerAccountRef: "ledger:hold",
},
},
Attributes: map[string]string{
"ledger_block_account_ref": "ledger:block",
"from_role": string(account_role.AccountRoleOperating),
"to_role": string(account_role.AccountRoleHold),
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if profile == nil {
t.Fatalf("expected profile")
}
if profile.Mode != model.FundingModeBalanceReserve {
t.Fatalf("expected balance_reserve mode, got %q", profile.Mode)
}
if profile.Reserve == nil {
t.Fatalf("expected reserve policy")
}
if got := profile.Reserve.DebitAccountRef; got != "ledger:operating" {
t.Fatalf("expected debit account ledger:operating, got %q", got)
}
if got := profile.Reserve.BlockAccountRef; got != "ledger:block" {
t.Fatalf("expected block account ledger:block, got %q", got)
}
if profile.Reserve.FromRole == nil || *profile.Reserve.FromRole != account_role.AccountRoleOperating {
t.Fatalf("expected from role operating, got %#v", profile.Reserve.FromRole)
}
if profile.Reserve.ToRole == nil || *profile.Reserve.ToRole != account_role.AccountRoleHold {
t.Fatalf("expected to role hold, got %#v", profile.Reserve.ToRole)
}
}
func TestStaticFundingProfileResolver_EmptyInputReturnsNil(t *testing.T) {
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{})
profile, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
OrganizationRef: "org-1",
Amount: &moneyv1.Money{
Amount: "10",
Currency: "USD",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if profile != nil {
t.Fatalf("expected nil profile")
}
}
func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) {
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
Profiles: map[string]*GatewayFundingProfile{
"monetix": {
Mode: model.FundingModeDepositObserved,
DepositCheck: &model.DepositCheckPolicy{
WalletRef: "wallet-deposit",
ExpectedAmount: &moneyv1.Money{
Amount: "15",
Currency: "USDT",
},
MinConfirmations: 2,
},
},
},
})
first, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
GatewayID: "monetix",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if first == nil || first.DepositCheck == nil {
t.Fatalf("expected configured profile")
}
first.DepositCheck.WalletRef = "changed"
second, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
GatewayID: "monetix",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if second == nil || second.DepositCheck == nil {
t.Fatalf("expected configured profile")
}
if second.DepositCheck.WalletRef != "wallet-deposit" {
t.Fatalf("expected cloned profile, got %q", second.DepositCheck.WalletRef)
}
}

View File

@@ -9,7 +9,6 @@ import (
"strings"
"time"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
@@ -131,7 +130,7 @@ func (h *quotePaymentCommand) quotePayment(
}
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) {
if err != nil && !errors.Is(err, quotestorage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) {
h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err),
mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey),
)
@@ -203,7 +202,7 @@ func (h *quotePaymentCommand) quotePayment(
record.SetOrganizationRef(qc.orgRef)
if err := quotesStore.Create(ctx, record); err != nil {
if errors.Is(err, storage.ErrDuplicateQuote) {
if errors.Is(err, quotestorage.ErrDuplicateQuote) {
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
if getErr == nil && existing != nil {
if existing.Hash != qc.hash {
@@ -422,7 +421,7 @@ func (h *quotePaymentsCommand) tryReuse(
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
if errors.Is(err, quotestorage.ErrQuoteNotFound) {
return nil, false, nil
}
h.logger.Warn(
@@ -538,7 +537,7 @@ func (h *quotePaymentsCommand) storeBatch(
record.SetOrganizationRef(qc.orgRef)
if err := quotesStore.Create(ctx, record); err != nil {
if errors.Is(err, storage.ErrDuplicateQuote) {
if errors.Is(err, quotestorage.ErrDuplicateQuote) {
rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc)
if reuseErr != nil {
return nil, reuseErr

View File

@@ -184,12 +184,12 @@ func (s *quoteCommandTestQuotesStore) GetByRef(_ context.Context, _ bson.ObjectI
return rec, nil
}
}
return nil, storage.ErrQuoteNotFound
return nil, quotestorage.ErrQuoteNotFound
}
func (s *quoteCommandTestQuotesStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
if rec, ok := s.byID[idempotencyKey]; ok {
return rec, nil
}
return nil, storage.ErrQuoteNotFound
return nil, quotestorage.ErrQuoteNotFound
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/shared"
"github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"

View File

@@ -0,0 +1,243 @@
package quotation_service_v2
import (
"strings"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2"
"github.com/tech/sendico/payments/storage/model"
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"
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"
)
func statusInputFromStatus(status quote_response_mapper_v2.QuoteStatus) *quote_persistence_service.StatusInput {
return &quote_persistence_service.StatusInput{
Kind: status.Kind,
Lifecycle: status.Lifecycle,
Executable: cloneBool(status.Executable),
BlockReason: status.BlockReason,
}
}
func statusFromStored(input *model.QuoteStatusV2) quote_response_mapper_v2.QuoteStatus {
if input == nil {
status := quote_response_mapper_v2.QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
}
status.Executable = boolPtr(true)
return status
}
status := quote_response_mapper_v2.QuoteStatus{
Kind: quoteKindToProto(input.Kind),
Lifecycle: quoteLifecycleToProto(input.Lifecycle),
Executable: cloneBool(input.Executable),
BlockReason: quoteBlockReasonToProto(input.BlockReason),
}
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED {
status.Kind = quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
}
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED {
status.Lifecycle = quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
}
return status
}
func quoteSnapshotFromComputed(src *quote_computation_service.ComputedQuote) *model.PaymentQuoteSnapshot {
if src == nil {
return nil
}
return &model.PaymentQuoteSnapshot{
DebitAmount: modelMoneyFromProto(src.DebitAmount),
ExpectedSettlementAmount: modelMoneyFromProto(src.CreditAmount),
TotalCost: modelMoneyFromProto(src.TotalCost),
FeeLines: feeLinesFromProto(src.FeeLines),
FeeRules: feeRulesFromProto(src.FeeRules),
Route: modelRouteFromProto(src.Route),
ExecutionConditions: modelExecutionConditionsFromProto(src.ExecutionConditions),
QuoteRef: strings.TrimSpace(src.QuoteRef),
FXQuote: modelFXQuoteFromProto(src.FXQuote),
}
}
func canonicalFromSnapshot(
snapshot *model.PaymentQuoteSnapshot,
expiresAt time.Time,
pricedAt time.Time,
fallbackQuoteRef string,
) quote_response_mapper_v2.CanonicalQuote {
if snapshot == nil {
return quote_response_mapper_v2.CanonicalQuote{
QuoteRef: strings.TrimSpace(fallbackQuoteRef),
ExpiresAt: expiresAt,
PricedAt: pricedAt,
}
}
return quote_response_mapper_v2.CanonicalQuote{
QuoteRef: firstNonEmpty(snapshot.QuoteRef, fallbackQuoteRef),
DebitAmount: protoMoneyFromModel(snapshot.DebitAmount),
CreditAmount: protoMoneyFromModel(snapshot.ExpectedSettlementAmount),
TotalCost: protoMoneyFromModel(snapshot.TotalCost),
FeeLines: feeLinesToProto(snapshot.FeeLines),
FeeRules: feeRulesToProto(snapshot.FeeRules),
Route: protoRouteFromModel(snapshot.Route),
Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions),
FXQuote: protoFXQuoteFromModel(snapshot.FXQuote),
ExpiresAt: expiresAt,
PricedAt: pricedAt,
}
}
func modelMoneyFromProto(src *moneyv1.Money) *paymenttypes.Money {
if src == nil {
return nil
}
return &paymenttypes.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
}
func protoMoneyFromModel(src *paymenttypes.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
}
func modelFXQuoteFromProto(src *oraclev1.Quote) *paymenttypes.FXQuote {
if src == nil {
return nil
}
modelQuote := &paymenttypes.FXQuote{
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
Side: sideFromProto(src.GetSide()),
Price: &paymenttypes.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())},
BaseAmount: modelMoneyFromProto(src.GetBaseAmount()),
QuoteAmount: modelMoneyFromProto(src.GetQuoteAmount()),
ExpiresAtUnixMs: src.GetExpiresAtUnixMs(),
Provider: strings.TrimSpace(src.GetProvider()),
RateRef: strings.TrimSpace(src.GetRateRef()),
Firm: src.GetFirm(),
}
if pair := src.GetPair(); pair != nil {
modelQuote.Pair = &paymenttypes.CurrencyPair{
Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())),
Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())),
}
}
if pricedAt := src.GetPricedAt(); pricedAt != nil {
modelQuote.PricedAtUnixMs = pricedAt.AsTime().UnixMilli()
}
return modelQuote
}
func protoFXQuoteFromModel(src *paymenttypes.FXQuote) *oraclev1.Quote {
if src == nil {
return nil
}
quote := &oraclev1.Quote{
QuoteRef: strings.TrimSpace(src.QuoteRef),
Side: sideToProto(src.GetSide()),
Price: &moneyv1.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())},
BaseAmount: protoMoneyFromModel(src.GetBaseAmount()),
QuoteAmount: protoMoneyFromModel(src.GetQuoteAmount()),
ExpiresAtUnixMs: src.GetExpiresAtUnixMs(),
Provider: strings.TrimSpace(src.GetProvider()),
RateRef: strings.TrimSpace(src.GetRateRef()),
Firm: src.GetFirm(),
}
if pair := src.GetPair(); pair != nil {
quote.Pair = &fxv1.CurrencyPair{
Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())),
Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())),
}
}
if src.GetPricedAtUnixMs() > 0 {
quote.PricedAt = timestamppb.New(time.UnixMilli(src.GetPricedAtUnixMs()))
}
return quote
}
func sideFromProto(side fxv1.Side) paymenttypes.FXSide {
switch side {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
return paymenttypes.FXSideBuyBaseSellQuote
case fxv1.Side_SELL_BASE_BUY_QUOTE:
return paymenttypes.FXSideSellBaseBuyQuote
default:
return paymenttypes.FXSideUnspecified
}
}
func sideToProto(side paymenttypes.FXSide) fxv1.Side {
switch side {
case paymenttypes.FXSideBuyBaseSellQuote:
return fxv1.Side_BUY_BASE_SELL_QUOTE
case paymenttypes.FXSideSellBaseBuyQuote:
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func quoteKindToProto(kind model.QuoteKind) quotationv2.QuoteKind {
switch kind {
case model.QuoteKindExecutable:
return quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
case model.QuoteKindIndicative:
return quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE
default:
return quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED
}
}
func quoteLifecycleToProto(lifecycle model.QuoteLifecycle) quotationv2.QuoteLifecycle {
switch lifecycle {
case model.QuoteLifecycleActive:
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
case model.QuoteLifecycleExpired:
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED
default:
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED
}
}
func quoteBlockReasonToProto(reason model.QuoteBlockReason) quotationv2.QuoteBlockReason {
switch reason {
case model.QuoteBlockReasonRouteUnavailable:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
case model.QuoteBlockReasonLimitBlocked:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED
case model.QuoteBlockReasonRiskBlocked:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED
case model.QuoteBlockReasonInsufficientLiquidity:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY
case model.QuoteBlockReasonPriceStale:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE
case model.QuoteBlockReasonAmountTooSmall:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL
case model.QuoteBlockReasonAmountTooLarge:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE
default:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
}
func cloneBool(src *bool) *bool {
if src == nil {
return nil
}
value := *src
return &value
}

View File

@@ -0,0 +1,195 @@
package quotation_service_v2
import (
"strings"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine {
if len(lines) == 0 {
return nil
}
result := make([]*paymenttypes.FeeLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, &paymenttypes.FeeLine{
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
Money: modelMoneyFromProto(line.GetMoney()),
LineType: postingLineTypeFromProto(line.GetLineType()),
Side: entrySideFromProto(line.GetSide()),
Meta: cloneStringMap(line.GetMeta()),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
if len(lines) == 0 {
return nil
}
result := make([]*feesv1.DerivedPostingLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, &feesv1.DerivedPostingLine{
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
Money: protoMoneyFromModel(line.GetMoney()),
LineType: postingLineTypeToProto(line.GetLineType()),
Side: entrySideToProto(line.GetSide()),
Meta: cloneStringMap(line.Meta),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule {
if len(rules) == 0 {
return nil
}
result := make([]*paymenttypes.AppliedRule, 0, len(rules))
for _, rule := range rules {
if rule == nil {
continue
}
result = append(result, &paymenttypes.AppliedRule{
RuleID: strings.TrimSpace(rule.GetRuleId()),
RuleVersion: strings.TrimSpace(rule.GetRuleVersion()),
Formula: strings.TrimSpace(rule.GetFormula()),
Rounding: roundingModeFromProto(rule.GetRounding()),
TaxCode: strings.TrimSpace(rule.GetTaxCode()),
TaxRate: strings.TrimSpace(rule.GetTaxRate()),
Parameters: cloneStringMap(rule.GetParameters()),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
if len(rules) == 0 {
return nil
}
result := make([]*feesv1.AppliedRule, 0, len(rules))
for _, rule := range rules {
if rule == nil {
continue
}
result = append(result, &feesv1.AppliedRule{
RuleId: strings.TrimSpace(rule.RuleID),
RuleVersion: strings.TrimSpace(rule.RuleVersion),
Formula: strings.TrimSpace(rule.Formula),
Rounding: roundingModeToProto(rule.Rounding),
TaxCode: strings.TrimSpace(rule.TaxCode),
TaxRate: strings.TrimSpace(rule.TaxRate),
Parameters: cloneStringMap(rule.Parameters),
})
}
if len(result) == 0 {
return nil
}
return result
}
func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide {
switch side {
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
return paymenttypes.EntrySideDebit
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
return paymenttypes.EntrySideCredit
default:
return paymenttypes.EntrySideUnspecified
}
}
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
switch side {
case paymenttypes.EntrySideDebit:
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case paymenttypes.EntrySideCredit:
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType {
switch lineType {
case accountingv1.PostingLineType_POSTING_LINE_FEE:
return paymenttypes.PostingLineTypeFee
case accountingv1.PostingLineType_POSTING_LINE_TAX:
return paymenttypes.PostingLineTypeTax
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
return paymenttypes.PostingLineTypeSpread
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
return paymenttypes.PostingLineTypeReversal
default:
return paymenttypes.PostingLineTypeUnspecified
}
}
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
switch lineType {
case paymenttypes.PostingLineTypeFee:
return accountingv1.PostingLineType_POSTING_LINE_FEE
case paymenttypes.PostingLineTypeTax:
return accountingv1.PostingLineType_POSTING_LINE_TAX
case paymenttypes.PostingLineTypeSpread:
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case paymenttypes.PostingLineTypeReversal:
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
}
}
func roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode {
switch mode {
case moneyv1.RoundingMode_ROUND_HALF_EVEN:
return paymenttypes.RoundingModeHalfEven
case moneyv1.RoundingMode_ROUND_HALF_UP:
return paymenttypes.RoundingModeHalfUp
case moneyv1.RoundingMode_ROUND_DOWN:
return paymenttypes.RoundingModeDown
default:
return paymenttypes.RoundingModeUnspecified
}
}
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
switch mode {
case paymenttypes.RoundingModeHalfEven:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
case paymenttypes.RoundingModeHalfUp:
return moneyv1.RoundingMode_ROUND_HALF_UP
case paymenttypes.RoundingModeDown:
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
}
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
result := make(map[string]string, len(input))
for k, v := range input {
result[k] = v
}
return result
}

View File

@@ -0,0 +1,49 @@
package quotation_service_v2
import (
"strings"
"time"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func boolPtr(value bool) *bool {
v := value
return &v
}
func minExpiry(values []time.Time) (time.Time, bool) {
var min time.Time
for _, value := range values {
if value.IsZero() {
continue
}
if min.IsZero() || value.Before(min) {
min = value
}
}
if min.IsZero() {
return time.Time{}, false
}
return min, true
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
}

View File

@@ -0,0 +1,167 @@
package quotation_service_v2
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func (s *QuotationServiceV2) ProcessQuotePayments(
ctx context.Context,
req *quotationv2.QuotePaymentsRequest,
) (*QuotePaymentsResult, error) {
if err := s.validateDependencies(); err != nil {
return nil, err
}
requestCtx, err := s.deps.Validator.ValidateQuotePayments(req)
if err != nil {
return nil, err
}
fingerprint := ""
if !requestCtx.PreviewOnly {
fingerprint = s.deps.Idempotency.FingerprintQuotePayments(req)
reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{
OrganizationID: requestCtx.OrganizationID,
IdempotencyKey: requestCtx.IdempotencyKey,
Fingerprint: fingerprint,
Shape: quote_idempotency_service.QuoteShapeBatch,
})
if reuseErr != nil {
return nil, reuseErr
}
if reused {
return s.batchResultFromRecord(reusedRecord)
}
}
hydrated, err := s.deps.Hydrator.HydrateMany(ctx, transfer_intent_hydrator.HydrateManyInput{
OrganizationRef: requestCtx.OrganizationRef,
InitiatorRef: requestCtx.InitiatorRef,
Intents: req.GetIntents(),
})
if err != nil {
return nil, err
}
quoteRef := ""
if !requestCtx.PreviewOnly {
quoteRef = normalizeQuoteRef(s.deps.NewRef())
if quoteRef == "" {
return nil, merrors.InvalidArgument("quote_ref is required")
}
}
collector := newItemCollector()
single := newSingleIntentProcessorV2(
s.deps.Computation,
s.deps.Classifier,
s.deps.ResponseMapper,
quoteRef,
s.deps.Now().UTC(),
collector,
)
batch := batch_quote_processor_v2.New(single)
batchOut, err := batch.Process(ctx, batch_quote_processor_v2.ProcessInput{
Context: newBatchContext(requestCtx),
Intents: hydrated,
})
if err != nil {
return nil, err
}
if batchOut == nil || len(batchOut.Items) != len(hydrated) {
return nil, merrors.InvalidArgument("batch quote output is invalid")
}
quotes := make([]*quotationv2.PaymentQuote, 0, len(batchOut.Items))
for _, item := range batchOut.Items {
if item == nil || item.Quote == nil {
return nil, merrors.InvalidArgument("batch item quote is required")
}
quotes = append(quotes, item.Quote)
}
details := collector.Ordered(len(batchOut.Items))
if len(details) != len(batchOut.Items) {
return nil, merrors.InvalidArgument("batch processing details are incomplete")
}
response := &quotationv2.QuotePaymentsResponse{
QuoteRef: quoteRef,
Quotes: quotes,
IdempotencyKey: strings.TrimSpace(requestCtx.IdempotencyKey),
}
result := &QuotePaymentsResult{
Response: response,
}
if requestCtx.PreviewOnly {
return result, nil
}
expires := make([]time.Time, 0, len(details))
intents := make([]model.PaymentIntent, 0, len(details))
snapshots := make([]*model.PaymentQuoteSnapshot, 0, len(details))
statuses := make([]*quote_persistence_service.StatusInput, 0, len(details))
for _, detail := range details {
if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil {
return nil, merrors.InvalidArgument("batch processing detail is incomplete")
}
expires = append(expires, detail.ExpiresAt)
intents = append(intents, detail.Intent)
snapshots = append(snapshots, quoteSnapshotFromComputed(detail.Quote))
statuses = append(statuses, statusInputFromStatus(detail.Status))
}
expiresAt, ok := minExpiry(expires)
if !ok {
return nil, merrors.InvalidArgument("expires_at is required")
}
record, err := s.deps.Persistence.BuildRecord(quote_persistence_service.PersistInput{
OrganizationID: requestCtx.OrganizationID,
QuoteRef: quoteRef,
IdempotencyKey: requestCtx.IdempotencyKey,
Hash: fingerprint,
ExpiresAt: expiresAt,
Intents: intents,
Quotes: snapshots,
Statuses: statuses,
})
if err != nil {
return nil, err
}
stored, reused, err := s.deps.Idempotency.CreateOrReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.CreateInput{
Record: record,
Reuse: quote_idempotency_service.ReuseInput{
OrganizationID: requestCtx.OrganizationID,
IdempotencyKey: requestCtx.IdempotencyKey,
Fingerprint: fingerprint,
Shape: quote_idempotency_service.QuoteShapeBatch,
},
})
if err != nil {
if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) ||
errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) {
return nil, merrors.InvalidArgument(err.Error())
}
return nil, err
}
if reused {
return s.batchResultFromRecord(stored)
}
result.Record = stored
return result, nil
}

View File

@@ -0,0 +1,148 @@
package quotation_service_v2
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func (s *QuotationServiceV2) ProcessQuotePayment(
ctx context.Context,
req *quotationv2.QuotePaymentRequest,
) (*QuotePaymentResult, error) {
if err := s.validateDependencies(); err != nil {
return nil, err
}
requestCtx, err := s.deps.Validator.ValidateQuotePayment(req)
if err != nil {
return nil, err
}
fingerprint := ""
if !requestCtx.PreviewOnly {
fingerprint = s.deps.Idempotency.FingerprintQuotePayment(req)
reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{
OrganizationID: requestCtx.OrganizationID,
IdempotencyKey: requestCtx.IdempotencyKey,
Fingerprint: fingerprint,
Shape: quote_idempotency_service.QuoteShapeSingle,
})
if reuseErr != nil {
return nil, reuseErr
}
if reused {
return s.singleResultFromRecord(reusedRecord)
}
}
hydrated, err := s.deps.Hydrator.HydrateOne(ctx, transfer_intent_hydrator.HydrateOneInput{
OrganizationRef: requestCtx.OrganizationRef,
InitiatorRef: requestCtx.InitiatorRef,
Intent: req.GetIntent(),
})
if err != nil {
return nil, err
}
quoteRef := ""
if !requestCtx.PreviewOnly {
quoteRef = normalizeQuoteRef(s.deps.NewRef())
if quoteRef == "" {
return nil, merrors.InvalidArgument("quote_ref is required")
}
}
collector := newItemCollector()
single := newSingleIntentProcessorV2(
s.deps.Computation,
s.deps.Classifier,
s.deps.ResponseMapper,
quoteRef,
s.deps.Now().UTC(),
collector,
)
batch := batch_quote_processor_v2.New(single)
batchOut, err := batch.Process(ctx, batch_quote_processor_v2.ProcessInput{
Context: newBatchContext(requestCtx),
Intents: []*transfer_intent_hydrator.QuoteIntent{hydrated},
})
if err != nil {
return nil, err
}
if batchOut == nil || len(batchOut.Items) != 1 || batchOut.Items[0] == nil || batchOut.Items[0].Quote == nil {
return nil, merrors.InvalidArgument("single quote output is invalid")
}
response := &quotationv2.QuotePaymentResponse{
Quote: batchOut.Items[0].Quote,
IdempotencyKey: strings.TrimSpace(requestCtx.IdempotencyKey),
}
detail, ok := collector.Get(0)
if !ok || detail == nil {
return nil, merrors.InvalidArgument("single processing detail is required")
}
result := &QuotePaymentResult{
Response: response,
}
if requestCtx.PreviewOnly {
return result, nil
}
expiresAt := detail.ExpiresAt
if expiresAt.IsZero() {
return nil, merrors.InvalidArgument("expires_at is required")
}
record, err := s.deps.Persistence.BuildRecord(quote_persistence_service.PersistInput{
OrganizationID: requestCtx.OrganizationID,
QuoteRef: quoteRef,
IdempotencyKey: requestCtx.IdempotencyKey,
Hash: fingerprint,
ExpiresAt: expiresAt,
Intent: pointerTo(detail.Intent),
Quote: quoteSnapshotFromComputed(detail.Quote),
Status: statusInputFromStatus(detail.Status),
})
if err != nil {
return nil, err
}
stored, reused, err := s.deps.Idempotency.CreateOrReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.CreateInput{
Record: record,
Reuse: quote_idempotency_service.ReuseInput{
OrganizationID: requestCtx.OrganizationID,
IdempotencyKey: requestCtx.IdempotencyKey,
Fingerprint: fingerprint,
Shape: quote_idempotency_service.QuoteShapeSingle,
},
})
if err != nil {
if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) ||
errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) {
return nil, merrors.InvalidArgument(err.Error())
}
return nil, err
}
if reused {
return s.singleResultFromRecord(stored)
}
result.Record = stored
return result, nil
}
func pointerTo(intent model.PaymentIntent) *model.PaymentIntent {
return &intent
}

View File

@@ -0,0 +1,16 @@
package quotation_service_v2
import (
"github.com/tech/sendico/payments/storage/model"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
type QuotePaymentResult struct {
Response *quotationv2.QuotePaymentResponse
Record *model.PaymentQuoteRecord
}
type QuotePaymentsResult struct {
Response *quotationv2.QuotePaymentsResponse
Record *model.PaymentQuoteRecord
}

View File

@@ -0,0 +1,81 @@
package quotation_service_v2
import (
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func (s *QuotationServiceV2) singleResultFromRecord(record *model.PaymentQuoteRecord) (*QuotePaymentResult, error) {
if record == nil {
return nil, merrors.InvalidArgument("record is required")
}
if record.Quote == nil {
return nil, merrors.InvalidArgument("record quote is required")
}
status := statusFromStored(record.StatusV2)
mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{
Meta: quote_response_mapper_v2.QuoteMeta{
ID: record.GetID().Hex(),
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
},
Quote: canonicalFromSnapshot(record.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
Status: status,
})
if err != nil {
return nil, err
}
return &QuotePaymentResult{
Response: &quotationv2.QuotePaymentResponse{
Quote: mapped.Quote,
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
},
Record: record,
}, nil
}
func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRecord) (*QuotePaymentsResult, error) {
if record == nil {
return nil, merrors.InvalidArgument("record is required")
}
if len(record.Quotes) == 0 {
return nil, merrors.InvalidArgument("record quotes are required")
}
quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Quotes))
for idx, snapshot := range record.Quotes {
var storedStatus *model.QuoteStatusV2
if idx < len(record.StatusesV2) {
storedStatus = record.StatusesV2[idx]
}
status := statusFromStored(storedStatus)
mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{
Meta: quote_response_mapper_v2.QuoteMeta{
ID: record.GetID().Hex(),
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
},
Quote: canonicalFromSnapshot(snapshot, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
Status: status,
})
if err != nil {
return nil, err
}
quotes = append(quotes, mapped.Quote)
}
return &QuotePaymentsResult{
Response: &quotationv2.QuotePaymentsResponse{
QuoteRef: strings.TrimSpace(record.QuoteRef),
Quotes: quotes,
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
},
Record: record,
}, nil
}

View File

@@ -0,0 +1,180 @@
package quotation_service_v2
import (
"strings"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func modelRouteFromProto(src *quotationv2.RouteSpecification) *paymenttypes.QuoteRouteSpecification {
if src == nil {
return nil
}
result := &paymenttypes.QuoteRouteSpecification{
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*paymenttypes.QuoteRouteHop, 0, len(hops))
for _, hop := range hops {
if hop == nil {
continue
}
result.Hops = append(result.Hops, &paymenttypes.QuoteRouteHop{
Index: hop.GetIndex(),
Rail: strings.TrimSpace(hop.GetRail()),
Gateway: strings.TrimSpace(hop.GetGateway()),
InstanceID: strings.TrimSpace(hop.GetInstanceId()),
Network: strings.TrimSpace(hop.GetNetwork()),
Role: routeHopRoleFromProto(hop.GetRole()),
})
}
if len(result.Hops) == 0 {
result.Hops = nil
}
}
return result
}
func protoRouteFromModel(src *paymenttypes.QuoteRouteSpecification) *quotationv2.RouteSpecification {
if src == nil {
return nil
}
result := &quotationv2.RouteSpecification{
Rail: strings.TrimSpace(src.Rail),
Provider: strings.TrimSpace(src.Provider),
PayoutMethod: strings.TrimSpace(src.PayoutMethod),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.SettlementAsset)),
SettlementModel: strings.TrimSpace(src.SettlementModel),
Network: strings.TrimSpace(src.Network),
RouteRef: strings.TrimSpace(src.RouteRef),
PricingProfileRef: strings.TrimSpace(src.PricingProfileRef),
}
if len(src.Hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops))
for _, hop := range src.Hops {
if hop == nil {
continue
}
result.Hops = append(result.Hops, &quotationv2.RouteHop{
Index: hop.Index,
Rail: strings.TrimSpace(hop.Rail),
Gateway: strings.TrimSpace(hop.Gateway),
InstanceId: strings.TrimSpace(hop.InstanceID),
Network: strings.TrimSpace(hop.Network),
Role: routeHopRoleToProto(hop.Role),
})
}
if len(result.Hops) == 0 {
result.Hops = nil
}
}
return result
}
func modelExecutionConditionsFromProto(src *quotationv2.ExecutionConditions) *paymenttypes.QuoteExecutionConditions {
if src == nil {
return nil
}
return &paymenttypes.QuoteExecutionConditions{
Readiness: readinessFromProto(src.GetReadiness()),
BatchingEligible: src.GetBatchingEligible(),
PrefundingRequired: src.GetPrefundingRequired(),
PrefundingCostIncluded: src.GetPrefundingCostIncluded(),
LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(),
LatencyHint: strings.TrimSpace(src.GetLatencyHint()),
Assumptions: cloneStringSlice(src.GetAssumptions()),
}
}
func protoExecutionConditionsFromModel(src *paymenttypes.QuoteExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil
}
return &quotationv2.ExecutionConditions{
Readiness: readinessToProto(src.Readiness),
BatchingEligible: src.BatchingEligible,
PrefundingRequired: src.PrefundingRequired,
PrefundingCostIncluded: src.PrefundingCostIncluded,
LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution,
LatencyHint: strings.TrimSpace(src.LatencyHint),
Assumptions: cloneStringSlice(src.Assumptions),
}
}
func readinessFromProto(src quotationv2.QuoteExecutionReadiness) paymenttypes.QuoteExecutionReadiness {
switch src {
case quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY:
return paymenttypes.QuoteExecutionReadinessLiquidityReady
case quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE:
return paymenttypes.QuoteExecutionReadinessLiquidityObtainable
case quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE:
return paymenttypes.QuoteExecutionReadinessIndicative
default:
return paymenttypes.QuoteExecutionReadinessUnspecified
}
}
func readinessToProto(src paymenttypes.QuoteExecutionReadiness) quotationv2.QuoteExecutionReadiness {
switch src {
case paymenttypes.QuoteExecutionReadinessLiquidityReady:
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY
case paymenttypes.QuoteExecutionReadinessLiquidityObtainable:
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE
case paymenttypes.QuoteExecutionReadinessIndicative:
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE
default:
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_UNSPECIFIED
}
}
func cloneStringSlice(src []string) []string {
if len(src) == 0 {
return nil
}
result := make([]string, 0, len(src))
for _, value := range src {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
func routeHopRoleFromProto(src quotationv2.RouteHopRole) paymenttypes.QuoteRouteHopRole {
switch src {
case quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE:
return paymenttypes.QuoteRouteHopRoleSource
case quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT:
return paymenttypes.QuoteRouteHopRoleTransit
case quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION:
return paymenttypes.QuoteRouteHopRoleDestination
default:
return paymenttypes.QuoteRouteHopRoleUnspecified
}
}
func routeHopRoleToProto(src paymenttypes.QuoteRouteHopRole) quotationv2.RouteHopRole {
switch src {
case paymenttypes.QuoteRouteHopRoleSource:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE
case paymenttypes.QuoteRouteHopRoleTransit:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT
case paymenttypes.QuoteRouteHopRoleDestination:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION
default:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_UNSPECIFIED
}
}

View File

@@ -0,0 +1,140 @@
package quotation_service_v2
import (
"context"
"strings"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_executability_classifier"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service"
quote_request_validator_v2 "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_request_validator"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Dependencies struct {
QuotesStore quotestorage.QuotesStore
Validator *quote_request_validator_v2.QuoteRequestValidatorV2
Hydrator *transfer_intent_hydrator.TransferIntentHydrator
Idempotency *quote_idempotency_service.QuoteIdempotencyService
Computation *quote_computation_service.QuoteComputationService
Classifier *quote_executability_classifier.QuoteExecutabilityClassifier
Persistence *quote_persistence_service.QuotePersistenceService
ResponseMapper *quote_response_mapper_v2.QuoteResponseMapperV2
Now func() time.Time
NewRef func() string
}
type QuotationServiceV2 struct {
deps Dependencies
quotationv2.UnimplementedQuotationServiceServer
}
func New(deps Dependencies) *QuotationServiceV2 {
if deps.Validator == nil {
deps.Validator = quote_request_validator_v2.New()
}
if deps.Idempotency == nil {
deps.Idempotency = quote_idempotency_service.New()
}
if deps.Classifier == nil {
deps.Classifier = quote_executability_classifier.New()
}
if deps.Persistence == nil {
deps.Persistence = quote_persistence_service.New()
}
if deps.ResponseMapper == nil {
deps.ResponseMapper = quote_response_mapper_v2.New()
}
if deps.Now == nil {
deps.Now = time.Now
}
if deps.NewRef == nil {
deps.NewRef = func() string { return bson.NewObjectID().Hex() }
}
return &QuotationServiceV2{deps: deps}
}
func (s *QuotationServiceV2) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) {
result, err := s.ProcessQuotePayment(ctx, req)
if err != nil {
return nil, err
}
return result.Response, nil
}
func (s *QuotationServiceV2) QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) {
result, err := s.ProcessQuotePayments(ctx, req)
if err != nil {
return nil, err
}
return result.Response, nil
}
func (s *QuotationServiceV2) validateDependencies() error {
if s == nil {
return merrors.InvalidArgument("service is required")
}
if s.deps.QuotesStore == nil {
return merrors.InvalidArgument("quotes store is required")
}
if s.deps.Validator == nil {
return merrors.InvalidArgument("validator is required")
}
if s.deps.Hydrator == nil {
return merrors.InvalidArgument("transfer intent hydrator is required")
}
if s.deps.Computation == nil {
return merrors.InvalidArgument("quote computation service is required")
}
if s.deps.Idempotency == nil {
return merrors.InvalidArgument("quote idempotency service is required")
}
if s.deps.Persistence == nil {
return merrors.InvalidArgument("quote persistence service is required")
}
if s.deps.ResponseMapper == nil {
return merrors.InvalidArgument("quote response mapper is required")
}
if s.deps.Classifier == nil {
return merrors.InvalidArgument("quote executability classifier is required")
}
if s.deps.Now == nil {
return merrors.InvalidArgument("now factory is required")
}
if s.deps.NewRef == nil {
return merrors.InvalidArgument("ref factory is required")
}
return nil
}
func quoteKindForPreview(previewOnly bool) quotationv2.QuoteKind {
if previewOnly {
return quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE
}
return quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
}
func normalizeQuoteRef(value string) string {
return strings.TrimSpace(value)
}
func newBatchContext(ctx *quote_request_validator_v2.Context) batch_quote_processor_v2.BatchContext {
if ctx == nil {
return batch_quote_processor_v2.BatchContext{}
}
return batch_quote_processor_v2.BatchContext{
OrganizationRef: strings.TrimSpace(ctx.OrganizationRef),
OrganizationID: ctx.OrganizationID,
InitiatorRef: strings.TrimSpace(ctx.InitiatorRef),
PreviewOnly: ctx.PreviewOnly,
BaseIdempotencyKey: strings.TrimSpace(ctx.IdempotencyKey),
}
}

View File

@@ -0,0 +1,718 @@
package quotation_service_v2
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/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/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
orgID := bson.NewObjectID()
store := newInMemoryQuotesStore()
core := &fakeQuoteCore{now: now}
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-single-usdt-rub" },
})
req := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{
OrganizationRef: orgID.Hex(),
},
IdempotencyKey: "idem-single-usdt-rub",
InitiatorRef: "initiator-42",
PreviewOnly: false,
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 got, want := quote.GetQuoteRef(), "quote-single-usdt-rub"; got != want {
t.Fatalf("unexpected quote_ref: got=%q want=%q", got, want)
}
if got, want := quote.GetKind(), quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE; got != want {
t.Fatalf("unexpected kind: got=%s want=%s", got.String(), want.String())
}
if got, want := quote.GetLifecycle(), quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE; got != want {
t.Fatalf("unexpected lifecycle: got=%s want=%s", got.String(), want.String())
}
if !quote.GetExecutable() {
t.Fatalf("expected executable=true")
}
if got, want := quote.GetDebitAmount().GetAmount(), "100"; got != want {
t.Fatalf("unexpected debit 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.GetCreditAmount().GetAmount(), "9150"; got != want {
t.Fatalf("unexpected credit 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.GetTotalCost().GetAmount(), "101.8"; got != want {
t.Fatalf("unexpected total_cost 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 := len(quote.GetFeeLines()), 2; got != want {
t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want)
}
if got, want := quote.GetFeeLines()[0].GetLineType(), accountingv1.PostingLineType_POSTING_LINE_FEE; got != want {
t.Fatalf("unexpected first fee line type: got=%s want=%s", got.String(), want.String())
}
if got, want := len(quote.GetFeeRules()), 1; got != want {
t.Fatalf("unexpected fee rules count: got=%d want=%d", got, want)
}
if got := strings.TrimSpace(quote.GetFeeRules()[0].GetRuleId()); got == "" || !strings.Contains(got, "fee_") {
t.Fatalf("expected route-bound fee rule id, got=%q", got)
}
if quote.GetFxQuote() == nil {
t.Fatalf("expected fx quote")
}
if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want {
t.Fatalf("unexpected fx base: got=%q want=%q", got, want)
}
if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want {
t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want)
}
if quote.GetRoute() == nil {
t.Fatalf("expected route specification")
}
if got, want := quote.GetRoute().GetRail(), "CARD_PAYOUT"; got != want {
t.Fatalf("unexpected route rail: got=%q want=%q", got, want)
}
if got, want := quote.GetRoute().GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected route provider: got=%q want=%q", got, want)
}
if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
t.Fatalf("expected route_ref")
}
if got := strings.TrimSpace(quote.GetRoute().GetPricingProfileRef()); got == "" {
t.Fatalf("expected pricing_profile_ref")
}
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if quote.GetExecutionConditions() == nil {
t.Fatalf("expected execution conditions")
}
if got, want := quote.GetExecutionConditions().GetReadiness(), quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY; got != want {
t.Fatalf("unexpected readiness: got=%s want=%s", got.String(), want.String())
}
if quote.GetExecutionConditions().GetPrefundingRequired() {
t.Fatalf("expected prefunding_required=false")
}
// Verify that idempotent reuse keeps full quote payload (including fee lines/rules).
reused, err := svc.ProcessQuotePayment(context.Background(), req)
if err != nil {
t.Fatalf("idempotent ProcessQuotePayment returned error: %v", err)
}
if reused == nil || reused.Response == nil || reused.Response.GetQuote() == nil {
t.Fatalf("expected idempotent quote response")
}
if got, want := len(reused.Response.GetQuote().GetFeeLines()), 2; got != want {
t.Fatalf("unexpected idempotent fee lines count: got=%d want=%d", got, want)
}
if got, want := len(reused.Response.GetQuote().GetFeeRules()), 1; got != want {
t.Fatalf("unexpected idempotent fee rules count: got=%d want=%d", got, want)
}
if got, want := reused.Response.GetQuote().GetRoute().GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected idempotent route provider: got=%q want=%q", got, want)
}
t.Logf("single request:\n%s", mustProtoJSON(t, req))
t.Logf("single response:\n%s", mustProtoJSON(t, result.Response))
}
func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
orgID := bson.NewObjectID()
store := newInMemoryQuotesStore()
core := &fakeQuoteCore{now: now}
svc := New(Dependencies{
QuotesStore: store,
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
return fmt.Sprintf("q-intent-%d", time.Now().UnixNano())
})),
Computation: quote_computation_service.New(core),
Now: func() time.Time { return now },
NewRef: func() string { return "quote-batch-usdt-rub" },
})
req := &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{
OrganizationRef: orgID.Hex(),
},
IdempotencyKey: "idem-batch-usdt-rub",
InitiatorRef: "initiator-42",
PreviewOnly: false,
Intents: []*transferv1.TransferIntent{
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"),
},
}
result, err := svc.ProcessQuotePayments(context.Background(), req)
if err != nil {
t.Fatalf("ProcessQuotePayments returned error: %v", err)
}
if result == nil || result.Response == nil {
t.Fatalf("expected batch response")
}
if got, want := result.Response.GetQuoteRef(), "quote-batch-usdt-rub"; got != want {
t.Fatalf("unexpected batch quote_ref: got=%q want=%q", got, want)
}
if got, want := len(result.Response.GetQuotes()), 3; got != want {
t.Fatalf("unexpected quote count: got=%d want=%d", got, want)
}
for i, quote := range result.Response.GetQuotes() {
if quote == nil {
t.Fatalf("quote[%d] is nil", i)
}
if quote.GetQuoteRef() != "quote-batch-usdt-rub" {
t.Fatalf("unexpected quote_ref for item %d: %q", i, quote.GetQuoteRef())
}
if !quote.GetExecutable() {
t.Fatalf("expected executable quote for item %d", i)
}
if quote.GetDebitAmount().GetCurrency() != "USDT" {
t.Fatalf("unexpected debit currency for item %d: %q", i, quote.GetDebitAmount().GetCurrency())
}
if quote.GetCreditAmount().GetCurrency() != "RUB" {
t.Fatalf("unexpected credit currency for item %d: %q", i, quote.GetCreditAmount().GetCurrency())
}
if quote.GetRoute() == nil {
t.Fatalf("expected route for item %d", i)
}
if got, want := quote.GetRoute().GetRail(), "CARD_PAYOUT"; got != want {
t.Fatalf("unexpected route rail for item %d: got=%q want=%q", i, got, want)
}
if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
t.Fatalf("expected route_ref for item %d", i)
}
if got := strings.TrimSpace(quote.GetRoute().GetPricingProfileRef()); got == "" {
t.Fatalf("expected pricing_profile_ref for item %d", i)
}
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
t.Fatalf("unexpected route hops count for item %d: got=%d want=%d", i, got, want)
}
if quote.GetExecutionConditions() == nil {
t.Fatalf("expected execution conditions for item %d", i)
}
if got, want := quote.GetExecutionConditions().GetReadiness(), quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY; got != want {
t.Fatalf("unexpected readiness for item %d: got=%s want=%s", i, got.String(), want.String())
}
if got, want := len(quote.GetFeeLines()), 2; got != want {
t.Fatalf("unexpected fee lines count for item %d: got=%d want=%d", i, 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)
}
}
if got, want := core.quoteRequestIdempotencyKeys, []string{
"idem-batch-usdt-rub:1",
"idem-batch-usdt-rub:2",
"idem-batch-usdt-rub:3",
}; !equalStrings(got, want) {
t.Fatalf("unexpected per-item idempotency keys: got=%v want=%v", got, want)
}
t.Logf("batch request:\n%s", mustProtoJSON(t, req))
t.Logf("batch response:\n%s", mustProtoJSON(t, result.Response))
}
func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
orgID := bson.NewObjectID()
store := newInMemoryQuotesStore()
core := &fakeQuoteCore{now: now}
svc := New(Dependencies{
QuotesStore: store,
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
return "q-intent-topology"
})),
Computation: quote_computation_service.New(
core,
quote_computation_service.WithGatewayRegistry(staticGatewayRegistryForE2E{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-disabled",
InstanceID: "crypto-disabled",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: false,
},
{
ID: "crypto-network-mismatch",
InstanceID: "crypto-network-mismatch",
Rail: model.RailCrypto,
Network: "ETH",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto-currency-mismatch",
InstanceID: "crypto-currency-mismatch",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"EUR"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto-gw-1",
InstanceID: "crypto-gw-1",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "payout-disabled",
InstanceID: "payout-disabled",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: false,
},
{
ID: "payout-currency-mismatch",
InstanceID: "payout-currency-mismatch",
Rail: model.RailCardPayout,
Currencies: []string{"EUR"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "payout-gw-1",
InstanceID: "payout-gw-1",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
},
}),
),
Now: func() time.Time { return now },
NewRef: func() string { return "quote-topology-usdt-rub" },
})
req := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{
OrganizationRef: orgID.Hex(),
},
IdempotencyKey: "idem-topology-usdt-rub",
InitiatorRef: "initiator-42",
PreviewOnly: false,
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.GetRoute() == nil {
t.Fatalf("expected route")
}
if got, want := quote.GetRoute().GetProvider(), "payout-gw-1"; got != want {
t.Fatalf("unexpected selected provider: got=%q want=%q", got, want)
}
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
t.Fatalf("unexpected hops count: got=%d want=%d", got, want)
}
if got, want := quote.GetRoute().GetHops()[0].GetGateway(), "crypto-gw-1"; got != want {
t.Fatalf("unexpected source hop gateway: got=%q want=%q", got, want)
}
if got, want := quote.GetRoute().GetHops()[1].GetGateway(), "internal"; got != want {
t.Fatalf("unexpected bridge hop gateway: got=%q want=%q", got, want)
}
if got, want := quote.GetRoute().GetHops()[2].GetGateway(), "payout-gw-1"; got != want {
t.Fatalf("unexpected destination hop gateway: 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)
}
}
func makeTransferIntent(
t *testing.T,
amount string,
currency string,
sourceWalletID string,
destinationPAN string,
destinationCountry string,
) *transferv1.TransferIntent {
t.Helper()
walletData, err := bson.Marshal(pkgmodel.WalletPaymentData{WalletID: sourceWalletID})
if err != nil {
t.Fatalf("failed to marshal wallet method data: %v", err)
}
cardData, err := bson.Marshal(pkgmodel.CardPaymentData{
Pan: destinationPAN,
FirstName: "Ivan",
LastName: "Petrov",
ExpMonth: "12",
ExpYear: "2032",
Country: destinationCountry,
})
if err != nil {
t.Fatalf("failed to marshal card method data: %v", err)
}
return &transferv1.TransferIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: walletData,
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
Data: cardData,
},
},
},
Amount: &moneyv1.Money{Amount: amount, Currency: currency},
}
}
func mustProtoJSON(t *testing.T, msg proto.Message) string {
t.Helper()
payload, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal proto json: %v", err)
}
return string(payload)
}
type fakeQuoteCore struct {
now time.Time
quoteRequestIdempotencyKeys []string
}
func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_service.BuildQuoteInput) (*quote_computation_service.ComputedQuote, time.Time, error) {
if strings.TrimSpace(in.IdempotencyKey) != "" {
f.quoteRequestIdempotencyKeys = append(f.quoteRequestIdempotencyKeys, strings.TrimSpace(in.IdempotencyKey))
}
if in.Route == nil {
return nil, time.Time{}, fmt.Errorf("selected route is required for route-bound quote pricing")
}
if in.ExecutionConditions == nil {
return nil, time.Time{}, fmt.Errorf("execution conditions are required for route-bound quote pricing")
}
if strings.TrimSpace(in.Route.GetRouteRef()) == "" {
return nil, time.Time{}, fmt.Errorf("route_ref is required for route-bound quote pricing")
}
if strings.TrimSpace(in.Route.GetPricingProfileRef()) == "" {
return nil, time.Time{}, fmt.Errorf("pricing_profile_ref is required for route-bound quote pricing")
}
if len(in.Route.GetHops()) == 0 {
return nil, time.Time{}, fmt.Errorf("route hops are required for route-bound quote pricing")
}
baseAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount())
rate := decimal.RequireFromString("91.5")
quoteAmount := baseAmount.Mul(rate)
feeAmount := decimal.RequireFromString("1.50")
taxAmount := decimal.RequireFromString("0.30")
if routeFeeClass(in.Route) != "card_payout:3_hops:monetix" {
feeAmount = decimal.RequireFromString("2.00")
taxAmount = decimal.RequireFromString("0.40")
}
quote := &quote_computation_service.ComputedQuote{
DebitAmount: &moneyv1.Money{
Amount: baseAmount.String(),
Currency: "USDT",
},
CreditAmount: &moneyv1.Money{
Amount: quoteAmount.String(),
Currency: "RUB",
},
FeeLines: []*feesv1.DerivedPostingLine{
{
LedgerAccountRef: "ledger:fees:usdt",
Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: "USDT"},
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
Meta: map[string]string{
"component": "platform_fee",
"provider": strings.TrimSpace(in.Route.GetProvider()),
},
},
{
LedgerAccountRef: "ledger:tax:usdt",
Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: "USDT"},
LineType: accountingv1.PostingLineType_POSTING_LINE_TAX,
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
Meta: map[string]string{
"component": "vat",
"provider": strings.TrimSpace(in.Route.GetProvider()),
},
},
},
FeeRules: []*feesv1.AppliedRule{
{
RuleId: "rule.platform.usdt." + strings.TrimSpace(in.Route.GetPricingProfileRef()),
RuleVersion: "2026-02-01",
Formula: "flat(1.50)+tax(0.30)",
Rounding: moneyv1.RoundingMode_ROUND_HALF_UP,
TaxCode: "VAT",
TaxRate: "0.20",
Parameters: map[string]string{
"country": "RU",
},
},
},
Route: cloneRouteSpecForTest(in.Route),
ExecutionConditions: cloneExecutionConditionsForTest(in.ExecutionConditions),
FXQuote: &oraclev1.Quote{
QuoteRef: "fx-usdt-rub",
Pair: &fxv1.CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Side: fxv1.Side_BUY_BASE_SELL_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(),
Provider: "test-oracle",
RateRef: "rate-usdt-rub",
Firm: true,
PricedAt: timestamppb.New(f.now),
},
}
return quote, f.now.Add(5 * time.Minute), nil
}
func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
if src == nil {
return nil
}
result := &quotationv2.RouteSpecification{
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
for _, hop := range hops {
if hop == nil {
continue
}
result.Hops = append(result.Hops, &quotationv2.RouteHop{
Index: hop.GetIndex(),
Rail: strings.TrimSpace(hop.GetRail()),
Gateway: strings.TrimSpace(hop.GetGateway()),
InstanceId: strings.TrimSpace(hop.GetInstanceId()),
Network: strings.TrimSpace(hop.GetNetwork()),
Role: hop.GetRole(),
})
}
if len(result.Hops) == 0 {
result.Hops = nil
}
}
return result
}
func cloneExecutionConditionsForTest(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil
}
result := &quotationv2.ExecutionConditions{
Readiness: src.GetReadiness(),
BatchingEligible: src.GetBatchingEligible(),
PrefundingRequired: src.GetPrefundingRequired(),
PrefundingCostIncluded: src.GetPrefundingCostIncluded(),
LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(),
LatencyHint: strings.TrimSpace(src.GetLatencyHint()),
}
if assumptions := src.GetAssumptions(); len(assumptions) > 0 {
result.Assumptions = make([]string, 0, len(assumptions))
for _, assumption := range assumptions {
trimmed := strings.TrimSpace(assumption)
if trimmed == "" {
continue
}
result.Assumptions = append(result.Assumptions, trimmed)
}
if len(result.Assumptions) == 0 {
result.Assumptions = nil
}
}
return result
}
func routeFeeClass(route *quotationv2.RouteSpecification) string {
if route == nil {
return ""
}
hops := route.GetHops()
destGateway := ""
if n := len(hops); n > 0 && hops[n-1] != nil {
destGateway = strings.ToLower(strings.TrimSpace(hops[n-1].GetGateway()))
}
return strings.ToLower(strings.TrimSpace(route.GetRail())) +
":" + fmt.Sprintf("%d_hops", len(hops)) +
":" + destGateway
}
type inMemoryQuotesStore struct {
byRef map[string]*model.PaymentQuoteRecord
byKey map[string]*model.PaymentQuoteRecord
}
type staticGatewayRegistryForE2E struct {
items []*model.GatewayInstanceDescriptor
}
func (r staticGatewayRegistryForE2E) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
if len(r.items) == 0 {
return nil, nil
}
out := make([]*model.GatewayInstanceDescriptor, 0, len(r.items))
for _, item := range r.items {
if item == nil {
continue
}
cloned := *item
if item.Currencies != nil {
cloned.Currencies = append([]string(nil), item.Currencies...)
}
out = append(out, &cloned)
}
return out, nil
}
func newInMemoryQuotesStore() *inMemoryQuotesStore {
return &inMemoryQuotesStore{
byRef: map[string]*model.PaymentQuoteRecord{},
byKey: map[string]*model.PaymentQuoteRecord{},
}
}
func (s *inMemoryQuotesStore) Create(_ context.Context, quote *model.PaymentQuoteRecord) error {
if quote == nil {
return merrors.InvalidArgument("quote is required")
}
if _, exists := s.byRef[quote.QuoteRef]; exists {
return quotestorage.ErrDuplicateQuote
}
if _, exists := s.byKey[quote.IdempotencyKey]; exists {
return quotestorage.ErrDuplicateQuote
}
s.byRef[quote.QuoteRef] = quote
s.byKey[quote.IdempotencyKey] = quote
return nil
}
func (s *inMemoryQuotesStore) GetByRef(_ context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
record, ok := s.byRef[strings.TrimSpace(quoteRef)]
if !ok || record == nil || record.GetOrganizationRef() != orgRef {
return nil, quotestorage.ErrQuoteNotFound
}
return record, nil
}
func (s *inMemoryQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
record, ok := s.byKey[strings.TrimSpace(idempotencyKey)]
if !ok || record == nil || record.GetOrganizationRef() != orgRef {
return nil, quotestorage.ErrQuoteNotFound
}
return record, nil
}
func equalStrings(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,182 @@
package quotation_service_v2
import (
"context"
"sort"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_executability_classifier"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
type itemProcessDetail struct {
Index int
Intent model.PaymentIntent
Quote *quote_computation_service.ComputedQuote
ExpiresAt time.Time
Status quote_response_mapper_v2.QuoteStatus
}
type itemCollector struct {
items map[int]*itemProcessDetail
}
func newItemCollector() *itemCollector {
return &itemCollector{items: make(map[int]*itemProcessDetail)}
}
func (c *itemCollector) Add(detail *itemProcessDetail) {
if c == nil || detail == nil {
return
}
c.items[detail.Index] = detail
}
func (c *itemCollector) Get(index int) (*itemProcessDetail, bool) {
if c == nil {
return nil, false
}
detail, ok := c.items[index]
return detail, ok
}
func (c *itemCollector) Ordered(count int) []*itemProcessDetail {
if c == nil || count <= 0 {
return nil
}
ordered := make([]*itemProcessDetail, 0, count)
indices := make([]int, 0, len(c.items))
for idx := range c.items {
indices = append(indices, idx)
}
sort.Ints(indices)
for _, idx := range indices {
ordered = append(ordered, c.items[idx])
}
return ordered
}
type singleIntentProcessorV2 struct {
computation *quote_computation_service.QuoteComputationService
classifier *quote_executability_classifier.QuoteExecutabilityClassifier
mapper *quote_response_mapper_v2.QuoteResponseMapperV2
quoteRef string
pricedAt time.Time
collector *itemCollector
}
func newSingleIntentProcessorV2(
computation *quote_computation_service.QuoteComputationService,
classifier *quote_executability_classifier.QuoteExecutabilityClassifier,
mapper *quote_response_mapper_v2.QuoteResponseMapperV2,
quoteRef string,
pricedAt time.Time,
collector *itemCollector,
) *singleIntentProcessorV2 {
return &singleIntentProcessorV2{
computation: computation,
classifier: classifier,
mapper: mapper,
quoteRef: quoteRef,
pricedAt: pricedAt,
collector: collector,
}
}
func (p *singleIntentProcessorV2) Process(
ctx context.Context,
in batch_quote_processor_v2.SingleProcessInput,
) (*batch_quote_processor_v2.SingleProcessOutput, error) {
if p == nil || p.computation == nil {
return nil, merrors.InvalidArgument("quote computation service is required")
}
if p.classifier == nil {
return nil, merrors.InvalidArgument("quote executability classifier is required")
}
if p.mapper == nil {
return nil, merrors.InvalidArgument("quote response mapper is required")
}
if in.Item.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
computed, err := p.computation.Compute(ctx, quote_computation_service.ComputeInput{
OrganizationRef: in.Context.OrganizationRef,
OrganizationID: in.Context.OrganizationID,
BaseIdempotencyKey: in.Item.IdempotencyKey,
PreviewOnly: in.Context.PreviewOnly,
Intents: []*transfer_intent_hydrator.QuoteIntent{in.Item.Intent},
})
if err != nil {
return nil, err
}
if computed == nil || computed.Plan == nil || len(computed.Results) != 1 || len(computed.Plan.Items) != 1 {
return nil, merrors.InvalidArgument("invalid computation output for single item")
}
result := computed.Results[0]
planItem := computed.Plan.Items[0]
if result == nil || planItem == nil || result.Quote == nil || planItem.Intent.Amount == nil {
return nil, merrors.InvalidArgument("incomplete computation output")
}
kind := quoteKindForPreview(in.Context.PreviewOnly)
lifecycle := quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
execution := p.classifier.BuildExecutionStatus(kind, lifecycle, result.BlockReason)
status := quote_response_mapper_v2.QuoteStatus{
Kind: kind,
Lifecycle: lifecycle,
}
if execution.IsSet() {
if execution.IsExecutable() {
status.Executable = boolPtr(true)
} else {
status.BlockReason = execution.BlockReason()
}
}
canonical := quote_response_mapper_v2.CanonicalQuote{
QuoteRef: p.quoteRef,
DebitAmount: cloneProtoMoney(result.Quote.DebitAmount),
CreditAmount: cloneProtoMoney(result.Quote.CreditAmount),
TotalCost: cloneProtoMoney(result.Quote.TotalCost),
FeeLines: result.Quote.FeeLines,
FeeRules: result.Quote.FeeRules,
FXQuote: result.Quote.FXQuote,
Route: result.Quote.Route,
Conditions: result.Quote.ExecutionConditions,
ExpiresAt: result.ExpiresAt,
PricedAt: p.pricedAt,
}
mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{
Quote: canonical,
Status: status,
})
if mapErr != nil {
return nil, mapErr
}
if mapped == nil || mapped.Quote == nil {
return nil, merrors.InvalidArgument("mapped quote is required")
}
p.collector.Add(&itemProcessDetail{
Index: in.Item.Index,
Intent: planItem.Intent,
Quote: result.Quote,
ExpiresAt: result.ExpiresAt,
Status: status,
})
return &batch_quote_processor_v2.SingleProcessOutput{
Quote: mapped.Quote,
}, nil
}

View File

@@ -0,0 +1,62 @@
package quote_computation_service
import (
"context"
"fmt"
"github.com/tech/sendico/pkg/merrors"
)
func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) (*ComputeOutput, error) {
if s == nil || s.core == nil {
return nil, merrors.InvalidArgument("quote computation core is required")
}
planModel, err := s.BuildPlan(ctx, in)
if err != nil {
return nil, err
}
results := make([]*QuoteComputationResult, 0, len(planModel.Items))
for _, item := range planModel.Items {
computed, computeErr := s.computePlanItem(ctx, item)
if computeErr != nil {
if item == nil {
return nil, computeErr
}
return nil, fmt.Errorf("Item %d: %w", item.Index, computeErr)
}
results = append(results, computed)
}
return &ComputeOutput{
Plan: planModel,
Results: results,
}, nil
}
func (s *QuoteComputationService) computePlanItem(
ctx context.Context,
item *QuoteComputationPlanItem,
) (*QuoteComputationResult, error) {
if item == nil || item.QuoteInput.Intent.Amount == nil {
return nil, merrors.InvalidArgument("plan item is required")
}
quote, expiresAt, err := s.core.BuildQuote(ctx, item.QuoteInput)
if err != nil {
return nil, err
}
enrichedQuote := ensureComputedQuote(quote, item)
if bindErr := validateQuoteRouteBinding(enrichedQuote, item.QuoteInput); bindErr != nil {
return nil, bindErr
}
result := &QuoteComputationResult{
ItemIndex: item.Index,
Quote: enrichedQuote,
ExpiresAt: expiresAt,
BlockReason: item.BlockReason,
}
return result, nil
}

View File

@@ -0,0 +1,501 @@
package quote_computation_service
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
svc := New(nil, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
GatewayModes: map[string]model.FundingMode{
"monetix": model.FundingModeBalanceReserve,
},
})))
orgID := bson.NewObjectID()
intent := sampleCardQuoteIntent()
intent.Attributes["ledger_block_account_ref"] = "ledger:block"
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if planModel == nil || len(planModel.Items) != 1 {
t.Fatalf("expected single plan item")
}
item := planModel.Items[0]
if item == nil {
t.Fatalf("expected plan item")
}
if item.IdempotencyKey != "idem-key" {
t.Fatalf("expected item idempotency key idem-key, got %q", item.IdempotencyKey)
}
if item.QuoteInput.IdempotencyKey != "idem-key" {
t.Fatalf("expected quote input idempotency key idem-key, got %q", item.QuoteInput.IdempotencyKey)
}
if len(item.Steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(item.Steps))
}
if item.Steps[0].Operation != model.RailOperationMove {
t.Fatalf("expected source operation MOVE, got %q", item.Steps[0].Operation)
}
if item.Steps[1].Operation != model.RailOperationSend {
t.Fatalf("expected destination operation SEND, got %q", item.Steps[1].Operation)
}
if item.Funding == nil {
t.Fatalf("expected funding gate")
}
if item.Funding.Mode != model.FundingModeBalanceReserve {
t.Fatalf("expected funding mode balance_reserve, got %q", item.Funding.Mode)
}
if item.Route == nil {
t.Fatalf("expected route specification")
}
if got, want := item.Route.GetRail(), "CARD_PAYOUT"; got != want {
t.Fatalf("unexpected route rail: got=%q want=%q", got, want)
}
if got := item.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
}
if got := item.Route.GetPricingProfileRef(); got == "" {
t.Fatalf("expected pricing_profile_ref")
}
if got, want := len(item.Route.GetHops()), 2; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if item.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
if !item.ExecutionConditions.GetPrefundingRequired() {
t.Fatalf("expected prefunding required for balance reserve mode")
}
}
func TestBuildPlan_RequiresFXAddsMiddleStep(t *testing.T) {
svc := New(nil)
orgID := bson.NewObjectID()
intent := sampleCardQuoteIntent()
intent.RequiresFX = true
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(planModel.Items) != 1 || len(planModel.Items[0].Steps) != 3 {
t.Fatalf("expected 3 steps for FX intent")
}
if got := planModel.Items[0].Steps[1].Operation; got != model.RailOperationFXConvert {
t.Fatalf("expected middle step FX_CONVERT, got %q", got)
}
if planModel.Items[0].Route == nil {
t.Fatalf("expected route specification")
}
if got, want := len(planModel.Items[0].Route.GetHops()), 3; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if got, want := planModel.Items[0].Route.GetHops()[1].GetRole(), quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT; got != want {
t.Fatalf("unexpected middle hop role: got=%s want=%s", got.String(), want.String())
}
}
func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-disabled",
InstanceID: "crypto-disabled",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: false,
},
{
ID: "crypto-network-mismatch",
InstanceID: "crypto-network-mismatch",
Rail: model.RailCrypto,
Network: "ETH",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto-currency-mismatch",
InstanceID: "crypto-currency-mismatch",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"EUR"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto-gw-1",
InstanceID: "crypto-gw-1",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "payout-disabled",
InstanceID: "payout-disabled",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: false,
},
{
ID: "payout-currency-mismatch",
InstanceID: "payout-currency-mismatch",
Rail: model.RailCardPayout,
Currencies: []string{"EUR"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "payout-gw-1",
InstanceID: "payout-gw-1",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "provider-ignored",
InstanceID: "provider-ignored",
Rail: model.RailProviderSettlement,
Network: "TRON",
Currencies: []string{"USDT"},
IsEnabled: true,
},
},
}))
orgID := bson.NewObjectID()
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if planModel == nil || len(planModel.Items) != 1 {
t.Fatalf("expected one plan item")
}
item := planModel.Items[0]
if item == nil {
t.Fatalf("expected non-nil plan item")
}
if got, want := len(item.Steps), 3; got != want {
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
}
if got, want := item.Steps[0].GatewayID, "crypto-gw-1"; got != want {
t.Fatalf("unexpected source gateway: got=%q want=%q", got, want)
}
if got, want := item.Steps[1].GatewayID, "internal"; got != want {
t.Fatalf("unexpected bridge gateway: got=%q want=%q", got, want)
}
if got, want := item.Steps[2].GatewayID, "payout-gw-1"; got != want {
t.Fatalf("unexpected destination gateway: got=%q want=%q", got, want)
}
if item.Route == nil {
t.Fatalf("expected route")
}
if got, want := item.Route.GetProvider(), "payout-gw-1"; got != want {
t.Fatalf("unexpected selected provider: got=%q want=%q", got, want)
}
if got, want := len(item.Route.GetHops()), 3; got != want {
t.Fatalf("unexpected route hop count: got=%d want=%d", got, want)
}
if got, want := item.Route.GetHops()[1].GetRail(), "LEDGER"; got != want {
t.Fatalf("unexpected bridge rail: got=%q want=%q", got, want)
}
if got, want := item.Route.GetHops()[1].GetRole(), quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT; got != want {
t.Fatalf("unexpected bridge role: got=%s want=%s", got.String(), want.String())
}
if got := item.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
}
if got := item.Route.GetPricingProfileRef(); got == "" {
t.Fatalf("expected pricing_profile_ref")
}
}
func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
core := &fakeCore{
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
FeeLines: []*feesv1.DerivedPostingLine{
{
Money: &moneyv1.Money{Amount: "1.50", Currency: "USD"},
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
},
},
},
expiresAt: time.Unix(1000, 0),
}
svc := New(core, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
GatewayModes: map[string]model.FundingMode{
"monetix": model.FundingModeBalanceReserve,
},
})))
orgID := bson.NewObjectID()
output, err := svc.Compute(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if output == nil || len(output.Results) != 1 {
t.Fatalf("expected single result")
}
result := output.Results[0]
if result == nil || result.Quote == nil {
t.Fatalf("expected result quote")
}
if result.Quote.Route == nil {
t.Fatalf("expected route specification")
}
if got, want := result.Quote.Route.GetPayoutMethod(), "CARD"; got != want {
t.Fatalf("unexpected payout method: got=%q want=%q", got, want)
}
if got := result.Quote.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
}
if got := result.Quote.Route.GetPricingProfileRef(); got == "" {
t.Fatalf("expected pricing_profile_ref")
}
if got, want := len(result.Quote.Route.GetHops()), 2; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if result.Quote.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
if got := result.Quote.ExecutionConditions.GetReadiness(); got != quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE {
t.Fatalf("unexpected readiness: %s", got.String())
}
if result.Quote.TotalCost == nil {
t.Fatalf("expected total cost")
}
if got, want := result.Quote.TotalCost.GetAmount(), "101.5"; got != want {
t.Fatalf("unexpected total cost amount: got=%q want=%q", got, want)
}
if core.lastQuoteIn.Route == nil {
t.Fatalf("expected selected route to be passed into build quote input")
}
if got, want := core.lastQuoteIn.Route.GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected selected route provider in build input: got=%q want=%q", got, want)
}
if core.lastQuoteIn.ExecutionConditions == nil {
t.Fatalf("expected execution conditions to be passed into build quote input")
}
if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got {
t.Fatalf("expected prefunding_required in build quote input for reserve mode")
}
}
func TestCompute_PreviewMarksIndicativeReadiness(t *testing.T) {
core := &fakeCore{
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
},
expiresAt: time.Unix(1000, 0),
}
svc := New(core)
orgID := bson.NewObjectID()
output, err := svc.Compute(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
PreviewOnly: true,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if output == nil || len(output.Results) != 1 {
t.Fatalf("expected single result")
}
if output.Results[0].Quote == nil || output.Results[0].Quote.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
if got := output.Results[0].Quote.ExecutionConditions.GetReadiness(); got != quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE {
t.Fatalf("unexpected readiness: %s", got.String())
}
}
func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) {
core := &fakeCore{
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Provider: "other-provider",
PayoutMethod: "CARD",
},
},
expiresAt: time.Unix(1000, 0),
}
svc := New(core)
orgID := bson.NewObjectID()
_, err := svc.Compute(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for route mismatch, got %v", err)
}
}
type fakeCore struct {
quote *ComputedQuote
expiresAt time.Time
quoteErr error
quoteCalls int
lastQuoteIn BuildQuoteInput
}
func (f *fakeCore) BuildQuote(_ context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error) {
f.quoteCalls++
f.lastQuoteIn = in
if f.quoteErr != nil {
return nil, time.Time{}, f.quoteErr
}
return f.quote, f.expiresAt, nil
}
func sampleCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent {
return &transfer_intent_hydrator.QuoteIntent{
Ref: "intent-1",
Kind: transfer_intent_hydrator.QuoteIntentKindPayout,
SettlementMode: transfer_intent_hydrator.QuoteSettlementModeFixSource,
Source: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeLedger,
Ledger: &transfer_intent_hydrator.QuoteLedgerEndpoint{
LedgerAccountRef: "ledger:src",
ContraLedgerAccountRef: "ledger:contra",
},
},
Destination: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeCard,
Card: &transfer_intent_hydrator.QuoteCardEndpoint{
Token: "tok_1",
},
},
Amount: &paymenttypes.Money{
Amount: "100",
Currency: "USD",
},
SettlementCurrency: "USD",
Attributes: map[string]string{
"gateway": "monetix",
},
}
}
func sampleCryptoToCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent {
return &transfer_intent_hydrator.QuoteIntent{
Ref: "intent-crypto-card",
Kind: transfer_intent_hydrator.QuoteIntentKindPayout,
SettlementMode: transfer_intent_hydrator.QuoteSettlementModeFixSource,
Source: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeManagedWallet,
ManagedWallet: &transfer_intent_hydrator.QuoteManagedWalletEndpoint{
ManagedWalletRef: "wallet-usdt-source",
Asset: &paymenttypes.Asset{
Chain: "TRON",
TokenSymbol: "USDT",
},
},
},
Destination: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeCard,
Card: &transfer_intent_hydrator.QuoteCardEndpoint{
Token: "tok_1",
},
},
Amount: &paymenttypes.Money{
Amount: "100",
Currency: "USDT",
},
SettlementCurrency: "USDT",
Attributes: map[string]string{},
}
}
type staticGatewayRegistry struct {
items []*model.GatewayInstanceDescriptor
}
func (r staticGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
if len(r.items) == 0 {
return nil, nil
}
out := make([]*model.GatewayInstanceDescriptor, 0, len(r.items))
for _, item := range r.items {
if item == nil {
continue
}
cloned := *item
if item.Currencies != nil {
cloned.Currencies = append([]string(nil), item.Currencies...)
}
out = append(out, &cloned)
}
return out, nil
}

View File

@@ -0,0 +1,136 @@
package quote_computation_service
import (
"strings"
"github.com/shopspring/decimal"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *ComputedQuote {
if src == nil {
src = &ComputedQuote{}
}
if item == nil {
return src
}
if src.Route == nil {
src.Route = cloneRouteSpecification(item.Route)
}
if src.ExecutionConditions == nil {
src.ExecutionConditions = cloneExecutionConditions(item.ExecutionConditions)
}
if src.TotalCost == nil {
src.TotalCost = deriveTotalCost(src.DebitAmount, src.FeeLines)
}
return src
}
func deriveTotalCost(
debitAmount *moneyv1.Money,
feeLines []*feesv1.DerivedPostingLine,
) *moneyv1.Money {
if debitAmount == nil {
return nil
}
currency := strings.ToUpper(strings.TrimSpace(debitAmount.GetCurrency()))
baseValue, err := decimal.NewFromString(strings.TrimSpace(debitAmount.GetAmount()))
if err != nil {
return nil
}
total := baseValue
for _, line := range feeLines {
if line == nil || line.GetMoney() == nil {
continue
}
lineCurrency := strings.ToUpper(strings.TrimSpace(line.GetMoney().GetCurrency()))
if lineCurrency == "" || lineCurrency != currency {
continue
}
lineAmount, convErr := decimal.NewFromString(strings.TrimSpace(line.GetMoney().GetAmount()))
if convErr != nil {
continue
}
switch line.GetSide() {
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
total = total.Add(lineAmount)
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
total = total.Sub(lineAmount)
}
}
return &moneyv1.Money{
Amount: total.String(),
Currency: currency,
}
}
func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
if src == nil {
return nil
}
result := &quotationv2.RouteSpecification{
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
for _, hop := range hops {
if cloned := cloneRouteHop(hop); cloned != nil {
result.Hops = append(result.Hops, cloned)
}
}
if len(result.Hops) == 0 {
result.Hops = nil
}
}
return result
}
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil
}
result := &quotationv2.ExecutionConditions{
Readiness: src.GetReadiness(),
BatchingEligible: src.GetBatchingEligible(),
PrefundingRequired: src.GetPrefundingRequired(),
PrefundingCostIncluded: src.GetPrefundingCostIncluded(),
LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(),
LatencyHint: strings.TrimSpace(src.GetLatencyHint()),
}
for _, assumption := range src.GetAssumptions() {
value := strings.TrimSpace(assumption)
if value == "" {
continue
}
result.Assumptions = append(result.Assumptions, value)
}
if len(result.Assumptions) == 0 {
result.Assumptions = nil
}
return result
}
func cloneRouteHop(src *quotationv2.RouteHop) *quotationv2.RouteHop {
if src == nil {
return nil
}
return &quotationv2.RouteHop{
Index: src.GetIndex(),
Rail: strings.TrimSpace(src.GetRail()),
Gateway: strings.TrimSpace(src.GetGateway()),
InstanceId: strings.TrimSpace(src.GetInstanceId()),
Network: strings.TrimSpace(src.GetNetwork()),
Role: src.GetRole(),
}
}

View File

@@ -0,0 +1,176 @@
package quote_computation_service
import (
"context"
"fmt"
"sort"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func (s *QuoteComputationService) resolveStepGateways(
ctx context.Context,
steps []*QuoteComputationStep,
routeNetwork string,
) error {
if s == nil || s.gatewayRegistry == nil {
return nil
}
gateways, err := s.gatewayRegistry.List(ctx)
if err != nil {
return err
}
if len(gateways) == 0 {
return merrors.InvalidArgument("gateway registry has no entries")
}
sorted := make([]*model.GatewayInstanceDescriptor, 0, len(gateways))
for _, gw := range gateways {
if gw != nil {
sorted = append(sorted, gw)
}
}
sort.Slice(sorted, func(i, j int) bool {
return strings.TrimSpace(sorted[i].ID) < strings.TrimSpace(sorted[j].ID)
})
for idx, step := range steps {
if step == nil {
continue
}
if strings.TrimSpace(step.GatewayID) != "" {
continue
}
if step.Rail == model.RailLedger {
step.GatewayID = "internal"
continue
}
selected, selectErr := selectGatewayForStep(sorted, step, routeNetwork)
if selectErr != nil {
return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr)
}
step.GatewayID = strings.TrimSpace(selected.ID)
if strings.TrimSpace(step.InstanceID) == "" {
step.InstanceID = strings.TrimSpace(selected.InstanceID)
}
}
return nil
}
func selectGatewayForStep(
gateways []*model.GatewayInstanceDescriptor,
step *QuoteComputationStep,
routeNetwork string,
) (*model.GatewayInstanceDescriptor, error) {
if step == nil {
return nil, merrors.InvalidArgument("step is required")
}
if len(gateways) == 0 {
return nil, merrors.InvalidArgument("gateway list is empty")
}
currency := ""
amount := decimal.Zero
if step.Amount != nil {
currency = strings.ToUpper(strings.TrimSpace(step.Amount.GetCurrency()))
if parsed, err := parseDecimalAmount(step.Amount); err == nil {
amount = parsed
}
}
action := gatewayEligibilityOperation(step.Operation)
direction := plan.SendDirectionForRail(step.Rail)
network := networkForGatewaySelection(step.Rail, routeNetwork)
var lastErr error
for _, gw := range gateways {
if gw == nil {
continue
}
if strings.TrimSpace(step.InstanceID) != "" &&
!strings.EqualFold(strings.TrimSpace(gw.InstanceID), strings.TrimSpace(step.InstanceID)) {
continue
}
if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil {
lastErr = err
continue
}
return gw, nil
}
if lastErr != nil {
return nil, merrors.InvalidArgument("no eligible gateway: " + lastErr.Error())
}
return nil, merrors.InvalidArgument("no eligible gateway")
}
func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) {
if m == nil {
return decimal.Zero, nil
}
value := strings.TrimSpace(m.GetAmount())
if value == "" {
return decimal.Zero, nil
}
parsed, err := decimal.NewFromString(value)
if err != nil {
return decimal.Zero, err
}
return parsed, nil
}
func gatewayEligibilityOperation(op model.RailOperation) model.RailOperation {
switch op {
case model.RailOperationExternalDebit, model.RailOperationExternalCredit:
return model.RailOperationSend
default:
return op
}
}
func networkForGatewaySelection(rail model.Rail, routeNetwork string) string {
switch rail {
case model.RailCrypto, model.RailProviderSettlement, model.RailFiatOnRamp:
return strings.ToUpper(strings.TrimSpace(routeNetwork))
default:
return ""
}
}
func hasExplicitDestinationGateway(attrs map[string]string) bool {
return strings.TrimSpace(firstNonEmpty(
lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"),
lookupAttr(attrs, "destination_gateway", "destinationGateway"),
)) != ""
}
func clearImplicitDestinationGateway(steps []*QuoteComputationStep) {
if len(steps) == 0 {
return
}
last := steps[len(steps)-1]
if last == nil {
return
}
last.GatewayID = ""
}
func destinationGatewayFromSteps(steps []*QuoteComputationStep) string {
for i := len(steps) - 1; i >= 0; i-- {
step := steps[i]
if step == nil {
continue
}
if gateway := normalizeGatewayKey(step.GatewayID); gateway != "" {
return gateway
}
}
return ""
}

View File

@@ -0,0 +1,167 @@
package quote_computation_service
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
const defaultCardGateway = "monetix"
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.TrimSpace(src.GetCurrency()),
}
}
func protoMoneyFromModel(src *paymenttypes.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
out := make(map[string]string, len(src))
for k, v := range src {
key := strings.TrimSpace(k)
if key == "" {
continue
}
out[key] = strings.TrimSpace(v)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset {
if src == nil {
return nil
}
return &paymenttypes.Asset{
Chain: strings.TrimSpace(src.Chain),
TokenSymbol: strings.TrimSpace(src.TokenSymbol),
ContractAddress: strings.TrimSpace(src.ContractAddress),
}
}
func cloneModelMoney(src *paymenttypes.Money) *paymenttypes.Money {
if src == nil {
return nil
}
return &paymenttypes.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
}
func clonePaymentIntent(src model.PaymentIntent) model.PaymentIntent {
out := model.PaymentIntent{
Ref: strings.TrimSpace(src.Ref),
Kind: src.Kind,
Source: clonePaymentEndpoint(src.Source),
Destination: clonePaymentEndpoint(src.Destination),
Amount: cloneModelMoney(src.Amount),
RequiresFX: src.RequiresFX,
FX: nil,
FeePolicy: src.FeePolicy,
SettlementMode: src.SettlementMode,
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
Attributes: cloneStringMap(src.Attributes),
Customer: src.Customer,
}
if src.FX != nil {
fx := *src.FX
out.FX = &fx
}
return out
}
func clonePaymentEndpoint(src model.PaymentEndpoint) model.PaymentEndpoint {
out := model.PaymentEndpoint{
Type: src.Type,
InstanceID: strings.TrimSpace(src.InstanceID),
Metadata: nil,
Ledger: nil,
ManagedWallet: nil,
ExternalChain: nil,
Card: nil,
}
if src.Ledger != nil {
out.Ledger = &model.LedgerEndpoint{
LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef),
ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef),
}
}
if src.ManagedWallet != nil {
out.ManagedWallet = &model.ManagedWalletEndpoint{
ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef),
Asset: cloneAsset(src.ManagedWallet.Asset),
}
}
if src.ExternalChain != nil {
out.ExternalChain = &model.ExternalChainEndpoint{
Asset: cloneAsset(src.ExternalChain.Asset),
Address: strings.TrimSpace(src.ExternalChain.Address),
Memo: strings.TrimSpace(src.ExternalChain.Memo),
}
}
if src.Card != nil {
out.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(src.Card.Pan),
Token: strings.TrimSpace(src.Card.Token),
Cardholder: strings.TrimSpace(src.Card.Cardholder),
CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname),
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: strings.TrimSpace(src.Card.Country),
MaskedPan: strings.TrimSpace(src.Card.MaskedPan),
}
}
if len(src.Metadata) > 0 {
out.Metadata = cloneStringMap(src.Metadata)
}
return out
}
func lookupAttr(attrs map[string]string, keys ...string) string {
if len(attrs) == 0 {
return ""
}
for _, key := range keys {
if key == "" {
continue
}
if val := strings.TrimSpace(attrs[key]); val != "" {
return val
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func normalizeGatewayKey(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@@ -0,0 +1,19 @@
package quote_computation_service
import (
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ComputeInput struct {
OrganizationRef string
OrganizationID bson.ObjectID
BaseIdempotencyKey string
PreviewOnly bool
Intents []*transfer_intent_hydrator.QuoteIntent
}
type ComputeOutput struct {
Plan *QuoteComputationPlan
Results []*QuoteComputationResult
}

View File

@@ -0,0 +1,108 @@
package quote_computation_service
import (
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
)
func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent {
if src == nil {
return model.PaymentIntent{}
}
settlementCurrency := strings.ToUpper(strings.TrimSpace(src.SettlementCurrency))
if settlementCurrency == "" && src.Amount != nil {
settlementCurrency = strings.ToUpper(strings.TrimSpace(src.Amount.Currency))
}
return model.PaymentIntent{
Ref: strings.TrimSpace(src.Ref),
Kind: modelPaymentKind(src.Kind, src.Destination),
Source: modelEndpointFromQuoteEndpoint(src.Source),
Destination: modelEndpointFromQuoteEndpoint(src.Destination),
Amount: cloneModelMoney(src.Amount),
RequiresFX: src.RequiresFX,
Attributes: cloneStringMap(src.Attributes),
SettlementMode: modelSettlementMode(src.SettlementMode),
SettlementCurrency: settlementCurrency,
}
}
func modelEndpointFromQuoteEndpoint(src transfer_intent_hydrator.QuoteEndpoint) model.PaymentEndpoint {
result := model.PaymentEndpoint{
Type: model.EndpointTypeUnspecified,
}
switch src.Type {
case transfer_intent_hydrator.QuoteEndpointTypeLedger:
result.Type = model.EndpointTypeLedger
if src.Ledger != nil {
result.Ledger = &model.LedgerEndpoint{
LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef),
ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef),
}
}
case transfer_intent_hydrator.QuoteEndpointTypeManagedWallet:
result.Type = model.EndpointTypeManagedWallet
if src.ManagedWallet != nil {
result.ManagedWallet = &model.ManagedWalletEndpoint{
ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef),
Asset: cloneAsset(src.ManagedWallet.Asset),
}
}
case transfer_intent_hydrator.QuoteEndpointTypeExternalChain:
result.Type = model.EndpointTypeExternalChain
if src.ExternalChain != nil {
result.ExternalChain = &model.ExternalChainEndpoint{
Asset: cloneAsset(src.ExternalChain.Asset),
Address: strings.TrimSpace(src.ExternalChain.Address),
Memo: strings.TrimSpace(src.ExternalChain.Memo),
}
}
case transfer_intent_hydrator.QuoteEndpointTypeCard:
result.Type = model.EndpointTypeCard
if src.Card != nil {
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(src.Card.Pan),
Token: strings.TrimSpace(src.Card.Token),
Cardholder: strings.TrimSpace(src.Card.Cardholder),
CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname),
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: strings.TrimSpace(src.Card.Country),
MaskedPan: strings.TrimSpace(src.Card.MaskedPan),
}
}
}
return result
}
func modelPaymentKind(kind transfer_intent_hydrator.QuoteIntentKind, destination transfer_intent_hydrator.QuoteEndpoint) model.PaymentKind {
switch kind {
case transfer_intent_hydrator.QuoteIntentKindPayout:
return model.PaymentKindPayout
case transfer_intent_hydrator.QuoteIntentKindInternalTransfer:
return model.PaymentKindInternalTransfer
case transfer_intent_hydrator.QuoteIntentKindFXConversion:
return model.PaymentKindFXConversion
}
switch destination.Type {
case transfer_intent_hydrator.QuoteEndpointTypeExternalChain, transfer_intent_hydrator.QuoteEndpointTypeCard:
return model.PaymentKindPayout
default:
return model.PaymentKindInternalTransfer
}
}
func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) model.SettlementMode {
switch mode {
case transfer_intent_hydrator.QuoteSettlementModeFixSource:
return model.SettlementModeFixSource
case transfer_intent_hydrator.QuoteSettlementModeFixReceived:
return model.SettlementModeFixReceived
default:
return model.SettlementModeUnspecified
}
}

View File

@@ -0,0 +1,95 @@
package quote_computation_service
import (
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/storage/model"
"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"
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"
)
// PlanMode defines whether the computation plan is for one intent or many.
type PlanMode string
const (
PlanModeUnspecified PlanMode = "unspecified"
PlanModeSingle PlanMode = "single"
PlanModeBatch PlanMode = "batch"
)
// BuildQuoteInput is the domain input for one quote computation request.
type BuildQuoteInput struct {
OrganizationRef string
IdempotencyKey string
PreviewOnly bool
Intent model.PaymentIntent
Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions
}
// ComputedQuote is a canonical quote payload for v2 processing.
type ComputedQuote struct {
QuoteRef string
DebitAmount *moneyv1.Money
CreditAmount *moneyv1.Money
TotalCost *moneyv1.Money
FeeLines []*feesv1.DerivedPostingLine
FeeRules []*feesv1.AppliedRule
FXQuote *oraclev1.Quote
Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions
}
// QuoteComputationPlan is an orchestration plan for quote computations.
// It is intentionally separate from executable payment plans.
type QuoteComputationPlan struct {
Mode PlanMode
OrganizationRef string
OrganizationID bson.ObjectID
PreviewOnly bool
BaseIdempotencyKey string
Items []*QuoteComputationPlanItem
}
// QuoteComputationPlanItem is one quote-computation unit.
type QuoteComputationPlanItem struct {
Index int
IdempotencyKey string
IntentRef string
Intent model.PaymentIntent
QuoteInput BuildQuoteInput
Steps []*QuoteComputationStep
Funding *gateway_funding_profile.QuoteFundingGate
Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions
BlockReason quotationv2.QuoteBlockReason
}
// QuoteComputationStep is one planner step in a generic execution graph.
// The planner should use rail+operation instead of custom per-case leg kinds.
type QuoteComputationStep struct {
StepID string
Rail model.Rail
Operation model.RailOperation
GatewayID string
InstanceID string
DependsOn []string
Amount *moneyv1.Money
FromRole *account_role.AccountRole
ToRole *account_role.AccountRole
Optional bool
IncludeInAggregate bool
}
// QuoteComputationResult is the computed output for one planned item.
type QuoteComputationResult struct {
ItemIndex int
Quote *ComputedQuote
ExpiresAt time.Time
BlockReason quotationv2.QuoteBlockReason
}

View File

@@ -0,0 +1,244 @@
package quote_computation_service
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput) (*QuoteComputationPlan, error) {
if strings.TrimSpace(in.OrganizationRef) == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if in.OrganizationID == bson.NilObjectID {
return nil, merrors.InvalidArgument("organization_id is required")
}
if len(in.Intents) == 0 {
return nil, merrors.InvalidArgument("intents are required")
}
if !in.PreviewOnly && strings.TrimSpace(in.BaseIdempotencyKey) == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
mode := PlanModeSingle
if len(in.Intents) > 1 {
mode = PlanModeBatch
}
planModel := &QuoteComputationPlan{
Mode: mode,
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
OrganizationID: in.OrganizationID,
PreviewOnly: in.PreviewOnly,
BaseIdempotencyKey: strings.TrimSpace(in.BaseIdempotencyKey),
Items: make([]*QuoteComputationPlanItem, 0, len(in.Intents)),
}
for i, intent := range in.Intents {
item, err := s.buildPlanItem(ctx, in, i, intent)
if err != nil {
return nil, fmt.Errorf("intents[%d]: %w", i, err)
}
planModel.Items = append(planModel.Items, item)
}
return planModel, nil
}
func (s *QuoteComputationService) buildPlanItem(
ctx context.Context,
in ComputeInput,
index int,
intent *transfer_intent_hydrator.QuoteIntent,
) (*QuoteComputationPlanItem, error) {
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
modelIntent := modelIntentFromQuoteIntent(intent)
if modelIntent.Amount == nil {
return nil, merrors.InvalidArgument("intent.amount is required")
}
if modelIntent.Source.Type == model.EndpointTypeUnspecified {
return nil, merrors.InvalidArgument("intent.source is required")
}
if modelIntent.Destination.Type == model.EndpointTypeUnspecified {
return nil, merrors.InvalidArgument("intent.destination is required")
}
itemIdempotencyKey := deriveItemIdempotencyKey(strings.TrimSpace(in.BaseIdempotencyKey), len(in.Intents), index)
source := clonePaymentEndpoint(modelIntent.Source)
destination := clonePaymentEndpoint(modelIntent.Destination)
_, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
if err != nil {
return nil, err
}
destRail, destNetwork, err := plan.RailFromEndpoint(destination, modelIntent.Attributes, false)
if err != nil {
return nil, err
}
routeNetwork, err := plan.ResolveRouteNetwork(modelIntent.Attributes, sourceNetwork, destNetwork)
if err != nil {
return nil, err
}
steps := buildComputationSteps(index, modelIntent, destination)
if modelIntent.Destination.Type == model.EndpointTypeCard &&
s.gatewayRegistry != nil &&
!hasExplicitDestinationGateway(modelIntent.Attributes) {
// Avoid sticky default provider when registry-driven selection is available.
clearImplicitDestinationGateway(steps)
}
if err := s.resolveStepGateways(
ctx,
steps,
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
); err != nil {
return nil, err
}
provider := firstNonEmpty(
destinationGatewayFromSteps(steps),
gatewayKeyForFunding(modelIntent.Attributes, destination),
)
if provider == "" && destRail == model.RailLedger {
provider = "internal"
}
funding, err := s.resolveFundingGate(ctx, resolveFundingGateInput{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
Rail: destRail,
Network: firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
Amount: protoMoneyFromModel(modelIntent.Amount),
Source: source,
Destination: destination,
Attributes: modelIntent.Attributes,
Currency: firstNonEmpty(
strings.TrimSpace(modelIntent.SettlementCurrency),
strings.TrimSpace(modelIntent.Amount.GetCurrency()),
),
GatewayID: provider,
InstanceID: instanceIDForFunding(modelIntent.Attributes),
})
if err != nil {
return nil, err
}
route := buildRouteSpecification(
modelIntent,
destination,
destRail,
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
provider,
steps,
)
conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding)
if route == nil || strings.TrimSpace(route.GetRail()) == "" || route.GetRail() == string(model.RailUnspecified) {
blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
}
quoteInput := BuildQuoteInput{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
IdempotencyKey: itemIdempotencyKey,
Intent: clonePaymentIntent(modelIntent),
PreviewOnly: in.PreviewOnly,
Route: cloneRouteSpecification(route),
ExecutionConditions: cloneExecutionConditions(conditions),
}
intentRef := strings.TrimSpace(modelIntent.Ref)
if intentRef == "" {
intentRef = fmt.Sprintf("intent-%d", index)
}
return &QuoteComputationPlanItem{
Index: index,
IdempotencyKey: itemIdempotencyKey,
IntentRef: intentRef,
Intent: modelIntent,
QuoteInput: quoteInput,
Steps: steps,
Funding: funding,
Route: route,
ExecutionConditions: conditions,
BlockReason: blockReason,
}, nil
}
func deriveItemIdempotencyKey(base string, total, index int) string {
base = strings.TrimSpace(base)
if base == "" {
return ""
}
if total <= 1 {
return base
}
return fmt.Sprintf("%s:%d", base, index+1)
}
func gatewayKeyForFunding(attrs map[string]string, destination model.PaymentEndpoint) string {
key := firstNonEmpty(
lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"),
lookupAttr(attrs, "destination_gateway", "destinationGateway"),
)
if key == "" && destination.Card != nil {
return defaultCardGateway
}
return normalizeGatewayKey(key)
}
func instanceIDForFunding(attrs map[string]string) string {
return strings.TrimSpace(lookupAttr(attrs,
"instance_id",
"instanceId",
"destination_instance_id",
"destinationInstanceId",
))
}
type resolveFundingGateInput struct {
OrganizationRef string
GatewayID string
InstanceID string
Rail model.Rail
Network string
Currency string
Amount *moneyv1.Money
Source model.PaymentEndpoint
Destination model.PaymentEndpoint
Attributes map[string]string
}
func (s *QuoteComputationService) resolveFundingGate(
ctx context.Context,
in resolveFundingGateInput,
) (*gateway_funding_profile.QuoteFundingGate, error) {
if s == nil || s.fundingResolver == nil {
return nil, nil
}
profile, err := s.fundingResolver.ResolveGatewayFundingProfile(ctx, gateway_funding_profile.FundingProfileRequest{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
GatewayID: normalizeGatewayKey(in.GatewayID),
InstanceID: strings.TrimSpace(in.InstanceID),
Rail: in.Rail,
Network: strings.TrimSpace(in.Network),
Currency: strings.ToUpper(strings.TrimSpace(in.Currency)),
Amount: in.Amount,
Source: &in.Source,
Destination: &in.Destination,
Attributes: in.Attributes,
})
if err != nil {
return nil, err
}
if profile == nil {
return nil, nil
}
return gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount)
}

View File

@@ -0,0 +1,147 @@
package quote_computation_service
import (
"fmt"
"strings"
"github.com/tech/sendico/payments/storage/model"
)
func buildComputationSteps(
index int,
intent model.PaymentIntent,
destination model.PaymentEndpoint,
) []*QuoteComputationStep {
if intent.Amount == nil {
return nil
}
attrs := intent.Attributes
amount := protoMoneyFromModel(intent.Amount)
sourceRail := sourceRailForIntent(intent)
destinationRail := destinationRailForIntent(intent)
sourceGatewayID := strings.TrimSpace(lookupAttr(attrs,
"source_gateway",
"sourceGateway",
"source_gateway_id",
"sourceGatewayId",
))
sourceInstanceID := strings.TrimSpace(lookupAttr(attrs, "source_instance_id", "sourceInstanceId"))
destinationGatewayID := gatewayKeyForFunding(attrs, destination)
destinationInstanceID := firstNonEmpty(
strings.TrimSpace(lookupAttr(attrs, "destination_instance_id", "destinationInstanceId")),
strings.TrimSpace(lookupAttr(attrs, "instance_id", "instanceId")),
)
sourceStepID := fmt.Sprintf("i%d.source", index)
steps := []*QuoteComputationStep{
{
StepID: sourceStepID,
Rail: sourceRail,
Operation: sourceOperationForRail(sourceRail),
GatewayID: sourceGatewayID,
InstanceID: sourceInstanceID,
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
},
}
lastStepID := sourceStepID
if intent.RequiresFX {
fxStepID := fmt.Sprintf("i%d.fx", index)
steps = append(steps, &QuoteComputationStep{
StepID: fxStepID,
Rail: model.RailProviderSettlement,
Operation: model.RailOperationFXConvert,
DependsOn: []string{sourceStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
})
lastStepID = fxStepID
}
if requiresTransitBridgeStep(sourceRail, destinationRail) {
bridgeStepID := fmt.Sprintf("i%d.bridge", index)
steps = append(steps, &QuoteComputationStep{
StepID: bridgeStepID,
Rail: model.RailLedger,
Operation: model.RailOperationMove,
DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
})
lastStepID = bridgeStepID
}
destinationStepID := fmt.Sprintf("i%d.destination", index)
steps = append(steps, &QuoteComputationStep{
StepID: destinationStepID,
Rail: destinationRail,
Operation: destinationOperationForRail(destinationRail),
GatewayID: destinationGatewayID,
InstanceID: destinationInstanceID,
DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: true,
})
return steps
}
func requiresTransitBridgeStep(sourceRail, destinationRail model.Rail) bool {
if sourceRail == model.RailUnspecified || destinationRail == model.RailUnspecified {
return false
}
if sourceRail == destinationRail {
return false
}
if sourceRail == model.RailLedger || destinationRail == model.RailLedger {
return false
}
return true
}
func sourceRailForIntent(intent model.PaymentIntent) model.Rail {
if intent.Source.Type == model.EndpointTypeLedger {
return model.RailLedger
}
if intent.Source.Type == model.EndpointTypeManagedWallet || intent.Source.Type == model.EndpointTypeExternalChain {
return model.RailCrypto
}
return model.RailLedger
}
func destinationRailForIntent(intent model.PaymentIntent) model.Rail {
switch intent.Destination.Type {
case model.EndpointTypeCard:
return model.RailCardPayout
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
return model.RailCrypto
case model.EndpointTypeLedger:
return model.RailLedger
default:
return model.RailProviderSettlement
}
}
func sourceOperationForRail(rail model.Rail) model.RailOperation {
if rail == model.RailLedger {
return model.RailOperationMove
}
return model.RailOperationExternalDebit
}
func destinationOperationForRail(rail model.Rail) model.RailOperation {
switch rail {
case model.RailLedger:
return model.RailOperationMove
case model.RailCardPayout:
return model.RailOperationSend
default:
return model.RailOperationExternalCredit
}
}

View File

@@ -0,0 +1,95 @@
package quote_computation_service
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func validateQuoteRouteBinding(quote *ComputedQuote, input BuildQuoteInput) error {
if quote == nil {
return merrors.InvalidArgument("computed quote is required")
}
if input.Route == nil {
return merrors.InvalidArgument("build_quote_input.route is required")
}
if quote.Route == nil {
return merrors.InvalidArgument("computed quote route is required")
}
if !sameRouteSpecification(quote.Route, input.Route) {
return merrors.InvalidArgument("computed quote route must match selected route")
}
return nil
}
func sameRouteSpecification(left, right *quotationv2.RouteSpecification) bool {
if left == nil || right == nil {
return left == right
}
return normalizeRail(left.GetRail()) == normalizeRail(right.GetRail()) &&
normalizeProvider(left.GetProvider()) == normalizeProvider(right.GetProvider()) &&
normalizePayoutMethod(left.GetPayoutMethod()) == normalizePayoutMethod(right.GetPayoutMethod()) &&
normalizeAsset(left.GetSettlementAsset()) == normalizeAsset(right.GetSettlementAsset()) &&
normalizeSettlementModel(left.GetSettlementModel()) == normalizeSettlementModel(right.GetSettlementModel()) &&
normalizeNetwork(left.GetNetwork()) == normalizeNetwork(right.GetNetwork()) &&
sameRouteReference(left.GetRouteRef(), right.GetRouteRef()) &&
samePricingProfileReference(left.GetPricingProfileRef(), right.GetPricingProfileRef()) &&
sameRouteHops(left.GetHops(), right.GetHops())
}
func normalizeRail(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func normalizeProvider(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizePayoutMethod(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func normalizeAsset(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func normalizeSettlementModel(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func normalizeNetwork(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func sameRouteReference(left, right string) bool {
return strings.TrimSpace(left) == strings.TrimSpace(right)
}
func samePricingProfileReference(left, right string) bool {
return strings.TrimSpace(left) == strings.TrimSpace(right)
}
func sameRouteHops(left, right []*quotationv2.RouteHop) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if !sameRouteHop(left[i], right[i]) {
return false
}
}
return true
}
func sameRouteHop(left, right *quotationv2.RouteHop) bool {
if left == nil || right == nil {
return left == right
}
return left.GetIndex() == right.GetIndex() &&
normalizeRail(left.GetRail()) == normalizeRail(right.GetRail()) &&
normalizeProvider(left.GetGateway()) == normalizeProvider(right.GetGateway()) &&
strings.TrimSpace(left.GetInstanceId()) == strings.TrimSpace(right.GetInstanceId()) &&
normalizeNetwork(left.GetNetwork()) == normalizeNetwork(right.GetNetwork()) &&
left.GetRole() == right.GetRole()
}

View File

@@ -0,0 +1,236 @@
package quote_computation_service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/storage/model"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func buildRouteSpecification(
intent model.PaymentIntent,
destination model.PaymentEndpoint,
destinationRail model.Rail,
network string,
provider string,
steps []*QuoteComputationStep,
) *quotationv2.RouteSpecification {
hops := buildRouteHops(steps, network)
if strings.TrimSpace(provider) == "" {
provider = providerFromHops(hops)
}
route := &quotationv2.RouteSpecification{
Rail: normalizeRail(string(destinationRail)),
Provider: normalizeProvider(provider),
PayoutMethod: normalizePayoutMethod(payoutMethodFromEndpoint(destination)),
SettlementAsset: normalizeAsset(intent.SettlementCurrency),
SettlementModel: normalizeSettlementModel(settlementModelString(intent.SettlementMode)),
Network: normalizeNetwork(network),
Hops: hops,
}
if route.SettlementAsset == "" && intent.Amount != nil {
route.SettlementAsset = normalizeAsset(intent.Amount.GetCurrency())
}
route.RouteRef = buildRouteReference(route)
route.PricingProfileRef = buildPricingProfileReference(route)
return route
}
func buildExecutionConditions(
previewOnly bool,
steps []*QuoteComputationStep,
funding *gateway_funding_profile.QuoteFundingGate,
) (*quotationv2.ExecutionConditions, quotationv2.QuoteBlockReason) {
blockReason := quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
conditions := &quotationv2.ExecutionConditions{
Readiness: quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY,
BatchingEligible: isBatchingEligible(steps),
PrefundingRequired: false,
PrefundingCostIncluded: false,
LiquidityCheckRequiredAtExecution: true,
LatencyHint: "instant",
Assumptions: []string{
"execution_time_liquidity_check",
"execution_time_provider_limits",
},
}
if previewOnly {
conditions.Readiness = quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE
conditions.LatencyHint = "indicative"
}
if funding != nil {
switch funding.Mode {
case model.FundingModeBalanceReserve:
conditions.PrefundingRequired = true
conditions.LatencyHint = "reserve_before_payout"
case model.FundingModeDepositObserved:
conditions.PrefundingRequired = true
conditions.LatencyHint = "deposit_confirmation_required"
}
}
if !previewOnly && conditions.PrefundingRequired {
conditions.Readiness = quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE
conditions.Assumptions = append(conditions.Assumptions, "prefunding_may_be_required_at_execution")
}
if !previewOnly && !conditions.PrefundingRequired {
conditions.Assumptions = append(conditions.Assumptions, "liquidity_expected_available_now")
}
return conditions, blockReason
}
func payoutMethodFromEndpoint(endpoint model.PaymentEndpoint) string {
switch endpoint.Type {
case model.EndpointTypeCard:
return "CARD"
case model.EndpointTypeExternalChain:
return "CRYPTO_ADDRESS"
case model.EndpointTypeManagedWallet:
return "MANAGED_WALLET"
case model.EndpointTypeLedger:
return "LEDGER"
default:
return "UNSPECIFIED"
}
}
func settlementModelString(mode model.SettlementMode) string {
switch mode {
case model.SettlementModeFixSource:
return "FIX_SOURCE"
case model.SettlementModeFixReceived:
return "FIX_RECEIVED"
default:
return "UNSPECIFIED"
}
}
func isBatchingEligible(steps []*QuoteComputationStep) bool {
for _, step := range steps {
if step != nil && step.IncludeInAggregate {
return true
}
}
return false
}
func buildRouteHops(steps []*QuoteComputationStep, fallbackNetwork string) []*quotationv2.RouteHop {
filtered := make([]*QuoteComputationStep, 0, len(steps))
for _, step := range steps {
if step == nil {
continue
}
filtered = append(filtered, step)
}
if len(filtered) == 0 {
return nil
}
result := make([]*quotationv2.RouteHop, 0, len(filtered))
lastIndex := len(filtered) - 1
for i, step := range filtered {
hop := &quotationv2.RouteHop{
Index: uint32(i + 1),
Rail: normalizeRail(string(step.Rail)),
Gateway: normalizeProvider(step.GatewayID),
InstanceId: strings.TrimSpace(step.InstanceID),
Network: normalizeNetwork(firstNonEmpty(fallbackNetwork)),
Role: roleForHopIndex(i, lastIndex),
}
if hop.Gateway == "" && hop.Rail == normalizeRail(string(model.RailLedger)) {
hop.Gateway = "internal"
}
result = append(result, hop)
}
return result
}
func roleForHopIndex(index, last int) quotationv2.RouteHopRole {
switch {
case index <= 0:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE
case index >= last:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION
default:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT
}
}
func providerFromHops(hops []*quotationv2.RouteHop) string {
for i := len(hops) - 1; i >= 0; i-- {
if hops[i] == nil {
continue
}
if gateway := normalizeProvider(hops[i].GetGateway()); gateway != "" {
return gateway
}
}
return ""
}
func buildRouteReference(route *quotationv2.RouteSpecification) string {
signature := routeTopologySignature(route, true)
if signature == "" {
return ""
}
sum := sha256.Sum256([]byte(signature))
return "rte_" + hex.EncodeToString(sum[:12])
}
func buildPricingProfileReference(route *quotationv2.RouteSpecification) string {
signature := routeTopologySignature(route, false)
if signature == "" {
return ""
}
sum := sha256.Sum256([]byte(signature))
return "fee_" + hex.EncodeToString(sum[:10])
}
func routeTopologySignature(route *quotationv2.RouteSpecification, includeInstances bool) string {
if route == nil {
return ""
}
parts := []string{
normalizeRail(route.GetRail()),
normalizeProvider(route.GetProvider()),
normalizePayoutMethod(route.GetPayoutMethod()),
normalizeAsset(route.GetSettlementAsset()),
normalizeSettlementModel(route.GetSettlementModel()),
normalizeNetwork(route.GetNetwork()),
}
hops := route.GetHops()
if len(hops) > 0 {
copied := make([]*quotationv2.RouteHop, 0, len(hops))
for _, hop := range hops {
if hop != nil {
copied = append(copied, hop)
}
}
sort.Slice(copied, func(i, j int) bool {
return copied[i].GetIndex() < copied[j].GetIndex()
})
for _, hop := range copied {
hopParts := []string{
fmt.Sprintf("%d", hop.GetIndex()),
normalizeRail(hop.GetRail()),
normalizeProvider(hop.GetGateway()),
normalizeNetwork(hop.GetNetwork()),
fmt.Sprintf("%d", hop.GetRole()),
}
if includeInstances {
hopParts = append(hopParts, strings.TrimSpace(hop.GetInstanceId()))
}
parts = append(parts, strings.Join(hopParts, ":"))
}
}
return strings.Join(parts, "|")
}

View File

@@ -0,0 +1,49 @@
package quote_computation_service
import (
"context"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
)
type Core interface {
BuildQuote(ctx context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error)
}
type Option func(*QuoteComputationService)
type QuoteComputationService struct {
core Core
fundingResolver gateway_funding_profile.FundingProfileResolver
gatewayRegistry plan.GatewayRegistry
}
func New(core Core, opts ...Option) *QuoteComputationService {
svc := &QuoteComputationService{
core: core,
}
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
return svc
}
func WithFundingProfileResolver(resolver gateway_funding_profile.FundingProfileResolver) Option {
return func(svc *QuoteComputationService) {
if svc != nil {
svc.fundingResolver = resolver
}
}
}
func WithGatewayRegistry(registry plan.GatewayRegistry) Option {
return func(svc *QuoteComputationService) {
if svc != nil {
svc.gatewayRegistry = registry
}
}
}

View File

@@ -37,7 +37,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef))
s.logger.Debug("Fx quote attached to payment quote", zap.String("org_ref", orgRef))
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
@@ -448,7 +448,7 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme
resp, err := client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err))
s.logger.Warn("Chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
@@ -510,7 +510,7 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quotat
quote, err := s.deps.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Warn("fx oracle quote failed", zap.Error(err))
s.logger.Warn("Fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error()))
}
if quote == nil {

View File

@@ -0,0 +1,175 @@
package quote_executability_classifier
import (
"errors"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
type blockReasonError struct {
reason quotationv2.QuoteBlockReason
cause error
}
func (e *blockReasonError) Error() string {
if e == nil {
return ""
}
if e.cause == nil {
return e.reason.String()
}
return e.cause.Error()
}
func (e *blockReasonError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func (e *blockReasonError) BlockReason() quotationv2.QuoteBlockReason {
if e == nil {
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
return e.reason
}
func Wrap(err error, reason quotationv2.QuoteBlockReason) error {
if err == nil {
return nil
}
return &blockReasonError{
reason: reason,
cause: err,
}
}
func WrapRouteUnavailable(err error) error {
return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE)
}
func WrapLimitBlocked(err error) error {
return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED)
}
func WrapRiskBlocked(err error) error {
return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED)
}
func WrapInsufficientLiquidity(err error) error {
return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY)
}
func WrapPriceStale(err error) error {
return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE)
}
func WrapAmountTooSmall(err error) error {
return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL)
}
func WrapAmountTooLarge(err error) error {
return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE)
}
func Extract(err error) (quotationv2.QuoteBlockReason, bool) {
var reasonErr interface {
BlockReason() quotationv2.QuoteBlockReason
}
if errors.As(err, &reasonErr) {
reason := reasonErr.BlockReason()
if reason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return reason, true
}
}
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, false
}
type ExecutionStatus struct {
set bool
executable bool
blockReason quotationv2.QuoteBlockReason
}
func (s ExecutionStatus) IsSet() bool {
return s.set
}
func (s ExecutionStatus) IsExecutable() bool {
return s.set && s.executable
}
func (s ExecutionStatus) BlockReason() quotationv2.QuoteBlockReason {
if !s.set || s.executable {
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
return s.blockReason
}
func (s ExecutionStatus) Apply(quote *quotationv2.PaymentQuote) {
if quote == nil {
return
}
if !s.set {
quote.ExecutionStatus = nil
return
}
if s.executable {
quote.ExecutionStatus = &quotationv2.PaymentQuote_Executable{Executable: true}
return
}
quote.ExecutionStatus = &quotationv2.PaymentQuote_BlockReason{
BlockReason: s.blockReason,
}
}
type QuoteExecutabilityClassifier struct{}
func New() *QuoteExecutabilityClassifier {
return &QuoteExecutabilityClassifier{}
}
func (c *QuoteExecutabilityClassifier) BuildExecutionStatus(
kind quotationv2.QuoteKind,
lifecycle quotationv2.QuoteLifecycle,
blockReason quotationv2.QuoteBlockReason,
) ExecutionStatus {
if kind != quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE ||
lifecycle != quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE {
return ExecutionStatus{}
}
if blockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return ExecutionStatus{
set: true,
executable: true,
}
}
return ExecutionStatus{
set: true,
executable: false,
blockReason: blockReason,
}
}
func (c *QuoteExecutabilityClassifier) BlockReasonFromError(err error) quotationv2.QuoteBlockReason {
if err == nil {
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
if reason, ok := Extract(err); ok {
return reason
}
switch {
case errors.Is(err, merrors.ErrNoData):
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
case errors.Is(err, merrors.ErrAccessDenied), errors.Is(err, merrors.ErrUnauthorized):
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED
case errors.Is(err, merrors.ErrInvalidArg):
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
default:
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
}

View File

@@ -0,0 +1,160 @@
package quote_executability_classifier
import (
"errors"
"testing"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestExtract_TypedReasonWrapper(t *testing.T) {
base := merrors.InvalidArgument("x")
err := WrapAmountTooSmall(base)
reason, ok := Extract(err)
if !ok {
t.Fatalf("expected extracted reason")
}
if reason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL {
t.Fatalf("unexpected reason: %s", reason.String())
}
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected wrapped error to preserve cause")
}
}
func TestBlockReasonFromError(t *testing.T) {
classifier := New()
tests := []struct {
name string
err error
want quotationv2.QuoteBlockReason
}{
{
name: "nil error",
err: nil,
want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
},
{
name: "typed wrapper wins",
err: WrapInsufficientLiquidity(merrors.InvalidArgument("x")),
want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY,
},
{
name: "no data fallback",
err: merrors.NoData("x"),
want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
},
{
name: "access denied fallback",
err: merrors.AccessDenied("payment", "execute", bson.NilObjectID),
want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED,
},
{
name: "invalid arg fallback",
err: merrors.InvalidArgument("x"),
want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
},
{
name: "unknown error",
err: errors.New("boom"),
want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifier.BlockReasonFromError(tt.err)
if got != tt.want {
t.Fatalf("unexpected block reason: got=%s want=%s", got.String(), tt.want.String())
}
})
}
}
func TestBuildExecutionStatus(t *testing.T) {
classifier := New()
activeExecutable := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
if !activeExecutable.IsSet() {
t.Fatalf("expected status to be set")
}
if !activeExecutable.IsExecutable() {
t.Fatalf("expected executable status")
}
blocked := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE,
)
if !blocked.IsSet() {
t.Fatalf("expected blocked status to be set")
}
if blocked.IsExecutable() {
t.Fatalf("expected blocked status")
}
if blocked.BlockReason() != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE {
t.Fatalf("unexpected block reason: %s", blocked.BlockReason().String())
}
indicative := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
)
if indicative.IsSet() {
t.Fatalf("expected no execution status for indicative quote")
}
expired := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
if expired.IsSet() {
t.Fatalf("expected no execution status for expired quote")
}
}
func TestApply(t *testing.T) {
classifier := New()
quote := &quotationv2.PaymentQuote{}
unset := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
unset.Apply(quote)
if quote.GetExecutionStatus() != nil {
t.Fatalf("expected unset execution status")
}
executable := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
executable.Apply(quote)
if !quote.GetExecutable() {
t.Fatalf("expected executable=true")
}
blocked := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY,
)
blocked.Apply(quote)
if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY {
t.Fatalf("unexpected block reason: %s", got.String())
}
}

View File

@@ -0,0 +1,8 @@
package quote_idempotency_service
import "errors"
var (
ErrIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
ErrIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape")
)

View File

@@ -0,0 +1,48 @@
package quote_idempotency_service
import (
"crypto/sha256"
"encoding/hex"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"google.golang.org/protobuf/proto"
)
func (s *QuoteIdempotencyService) FingerprintQuotePayment(req *quotationv2.QuotePaymentRequest) string {
if req == nil {
return hashBytes([]byte("nil_request"))
}
cloned := proto.Clone(req).(*quotationv2.QuotePaymentRequest)
cloned.Meta = nil
cloned.IdempotencyKey = ""
cloned.PreviewOnly = false
return fingerprintMessage(cloned)
}
func (s *QuoteIdempotencyService) FingerprintQuotePayments(req *quotationv2.QuotePaymentsRequest) string {
if req == nil {
return hashBytes([]byte("nil_request"))
}
cloned := proto.Clone(req).(*quotationv2.QuotePaymentsRequest)
cloned.Meta = nil
cloned.IdempotencyKey = ""
cloned.PreviewOnly = false
return fingerprintMessage(cloned)
}
func fingerprintMessage(msg proto.Message) string {
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(msg)
if err != nil {
return hashBytes([]byte("marshal_error"))
}
return hashBytes(b)
}
func hashBytes(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,120 @@
package quote_idempotency_service
import (
"testing"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
)
func TestFingerprintQuotePayment_IgnoresTransportFields(t *testing.T) {
svc := New()
base := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a",
Intent: testTransferIntent("10"),
PreviewOnly: false,
InitiatorRef: "actor-1",
}
alt := proto.Clone(base).(*quotationv2.QuotePaymentRequest)
alt.Meta = &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}
alt.IdempotencyKey = "idem-b"
alt.PreviewOnly = true
if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(alt); got != want {
t.Fatalf("expected same fingerprint, got %q != %q", got, want)
}
}
func TestFingerprintQuotePayment_DetectsBusinessPayloadChanges(t *testing.T) {
svc := New()
base := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a",
Intent: testTransferIntent("10"),
InitiatorRef: "actor-1",
}
changedInitiator := proto.Clone(base).(*quotationv2.QuotePaymentRequest)
changedInitiator.InitiatorRef = "actor-2"
if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(changedInitiator); got == want {
t.Fatalf("expected different fingerprint for initiator change")
}
changedAmount := proto.Clone(base).(*quotationv2.QuotePaymentRequest)
changedAmount.Intent = testTransferIntent("11")
if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(changedAmount); got == want {
t.Fatalf("expected different fingerprint for amount change")
}
}
func TestFingerprintQuotePayments_IgnoresTransportFields(t *testing.T) {
svc := New()
base := &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a",
Intents: []*transferv1.TransferIntent{
testTransferIntent("10"),
testTransferIntent("20"),
},
PreviewOnly: false,
InitiatorRef: "actor-1",
}
alt := proto.Clone(base).(*quotationv2.QuotePaymentsRequest)
alt.Meta = &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}
alt.IdempotencyKey = "idem-b"
alt.PreviewOnly = true
if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(alt); got != want {
t.Fatalf("expected same fingerprint, got %q != %q", got, want)
}
}
func TestFingerprintQuotePayments_DetectsBusinessPayloadChanges(t *testing.T) {
svc := New()
base := &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()},
IdempotencyKey: "idem-a",
Intents: []*transferv1.TransferIntent{
testTransferIntent("10"),
testTransferIntent("20"),
},
InitiatorRef: "actor-1",
}
changedInitiator := proto.Clone(base).(*quotationv2.QuotePaymentsRequest)
changedInitiator.InitiatorRef = "actor-2"
if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(changedInitiator); got == want {
t.Fatalf("expected different fingerprint for initiator change")
}
reordered := proto.Clone(base).(*quotationv2.QuotePaymentsRequest)
reordered.Intents = []*transferv1.TransferIntent{
testTransferIntent("20"),
testTransferIntent("10"),
}
if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(reordered); got == want {
t.Fatalf("expected different fingerprint for intent order change")
}
}
func testTransferIntent(amount string) *transferv1.TransferIntent {
return &transferv1.TransferIntent{
Source: endpointWithMethodRef("pm-src"),
Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: amount, Currency: "USD"},
}
}
func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint {
return &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{
PaymentMethodRef: methodRef,
},
}
}

View File

@@ -0,0 +1,113 @@
package quote_idempotency_service
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ReuseInput struct {
OrganizationID bson.ObjectID
IdempotencyKey string
Fingerprint string
Shape QuoteShape
}
type CreateInput struct {
Record *model.PaymentQuoteRecord
Reuse ReuseInput
}
func (s *QuoteIdempotencyService) TryReuse(
ctx context.Context,
quotesStore quotestorage.QuotesStore,
in ReuseInput,
) (*model.PaymentQuoteRecord, bool, error) {
if quotesStore == nil {
return nil, false, merrors.InvalidArgument("quotes store is required")
}
if in.OrganizationID == bson.NilObjectID {
return nil, false, merrors.InvalidArgument("organization_id is required")
}
idempotencyKey := strings.TrimSpace(in.IdempotencyKey)
if idempotencyKey == "" {
return nil, false, merrors.InvalidArgument("idempotency_key is required")
}
fingerprint := strings.TrimSpace(in.Fingerprint)
if fingerprint == "" {
return nil, false, merrors.InvalidArgument("fingerprint is required")
}
record, err := quotesStore.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey)
if err != nil {
if errors.Is(err, quotestorage.ErrQuoteNotFound) || errors.Is(err, merrors.ErrNoData) {
return nil, false, nil
}
return nil, false, err
}
if record == nil {
return nil, false, nil
}
if !shapeMatches(record, in.Shape) {
return nil, false, ErrIdempotencyShapeMismatch
}
if strings.TrimSpace(record.Hash) != fingerprint {
return nil, false, ErrIdempotencyParamMismatch
}
return record, true, nil
}
func (s *QuoteIdempotencyService) CreateOrReuse(
ctx context.Context,
quotesStore quotestorage.QuotesStore,
in CreateInput,
) (*model.PaymentQuoteRecord, bool, error) {
if quotesStore == nil {
return nil, false, merrors.InvalidArgument("quotes store is required")
}
if in.Record == nil {
return nil, false, merrors.InvalidArgument("record is required")
}
if err := quotesStore.Create(ctx, in.Record); err != nil {
if !errors.Is(err, quotestorage.ErrDuplicateQuote) {
return nil, false, err
}
record, reused, reuseErr := s.TryReuse(ctx, quotesStore, in.Reuse)
if reuseErr != nil {
return nil, false, reuseErr
}
if reused {
return record, true, nil
}
return nil, false, err
}
return in.Record, false, nil
}
func shapeMatches(record *model.PaymentQuoteRecord, shape QuoteShape) bool {
if record == nil {
return false
}
switch shape {
case QuoteShapeSingle:
return len(record.Quotes) == 0
case QuoteShapeBatch:
return len(record.Quotes) > 0
default:
return true
}
}

View File

@@ -0,0 +1,288 @@
package quote_idempotency_service
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestTryReuse_NotFound(t *testing.T) {
svc := New()
store := &fakeQuotesStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return nil, quotestorage.ErrQuoteNotFound
},
}
record, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
Shape: QuoteShapeSingle,
})
if err != nil {
t.Fatalf("TryReuse returned error: %v", err)
}
if reused {
t.Fatalf("expected reused=false")
}
if record != nil {
t.Fatalf("expected nil record")
}
}
func TestTryReuse_ParamMismatch(t *testing.T) {
svc := New()
store := &fakeQuotesStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "stored-hash",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
}, nil
},
}
_, _, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "different-hash",
Shape: QuoteShapeSingle,
})
if !errors.Is(err, ErrIdempotencyParamMismatch) {
t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err)
}
}
func TestTryReuse_ShapeMismatch(t *testing.T) {
svc := New()
store := &fakeQuotesStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "hash-1",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
}, nil
},
}
_, _, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
Shape: QuoteShapeBatch,
})
if !errors.Is(err, ErrIdempotencyShapeMismatch) {
t.Fatalf("expected ErrIdempotencyShapeMismatch, got %v", err)
}
}
func TestTryReuse_ShapeMismatchSingle(t *testing.T) {
svc := New()
store := &fakeQuotesStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "hash-1",
Quotes: []*model.PaymentQuoteSnapshot{
{QuoteRef: "q1"},
},
}, nil
},
}
_, _, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
Shape: QuoteShapeSingle,
})
if !errors.Is(err, ErrIdempotencyShapeMismatch) {
t.Fatalf("expected ErrIdempotencyShapeMismatch, got %v", err)
}
}
func TestTryReuse_Success(t *testing.T) {
svc := New()
existing := &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "hash-1",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
}
store := &fakeQuotesStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return existing, nil
},
}
record, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
Shape: QuoteShapeSingle,
})
if err != nil {
t.Fatalf("TryReuse returned error: %v", err)
}
if !reused {
t.Fatalf("expected reused=true")
}
if record != existing {
t.Fatalf("expected existing record to be returned")
}
}
func TestCreateOrReuse_CreateSuccess(t *testing.T) {
svc := New()
store := &fakeQuotesStore{
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return nil },
}
record := &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "hash-1",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
}
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Record: record,
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
Shape: QuoteShapeSingle,
},
})
if err != nil {
t.Fatalf("CreateOrReuse returned error: %v", err)
}
if reused {
t.Fatalf("expected reused=false")
}
if got != record {
t.Fatalf("expected newly created record")
}
}
func TestCreateOrReuse_DuplicateReturnsExisting(t *testing.T) {
svc := New()
existing := &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "hash-1",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
}
store := &fakeQuotesStore{
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote },
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return existing, nil
},
}
record := &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "hash-1",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
}
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Record: record,
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
Shape: QuoteShapeSingle,
},
})
if err != nil {
t.Fatalf("CreateOrReuse returned error: %v", err)
}
if !reused {
t.Fatalf("expected reused=true")
}
if got != existing {
t.Fatalf("expected existing record")
}
}
func TestCreateOrReuse_DuplicateParamMismatch(t *testing.T) {
svc := New()
store := &fakeQuotesStore{
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote },
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "stored-hash",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
}, nil
},
}
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Record: &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "new-hash",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
},
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "new-hash",
Shape: QuoteShapeSingle,
},
})
if !errors.Is(err, ErrIdempotencyParamMismatch) {
t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err)
}
}
func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing.T) {
svc := New()
store := &fakeQuotesStore{
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote },
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return nil, quotestorage.ErrQuoteNotFound
},
}
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Record: &model.PaymentQuoteRecord{
IdempotencyKey: "idem-1",
Hash: "hash-1",
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
},
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
Shape: QuoteShapeSingle,
},
})
if !errors.Is(err, quotestorage.ErrDuplicateQuote) {
t.Fatalf("expected ErrDuplicateQuote, got %v", err)
}
}
type fakeQuotesStore struct {
createFn func(ctx context.Context, quote *model.PaymentQuoteRecord) error
getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error)
}
func (f *fakeQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
if f.createFn == nil {
return nil
}
return f.createFn(ctx, quote)
}
func (f *fakeQuotesStore) GetByRef(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return nil, quotestorage.ErrQuoteNotFound
}
func (f *fakeQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
if f.getByIdempotencyKeyFn == nil {
return nil, quotestorage.ErrQuoteNotFound
}
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
}

View File

@@ -0,0 +1,15 @@
package quote_idempotency_service
type QuoteShape string
const (
QuoteShapeUnspecified QuoteShape = "unspecified"
QuoteShapeSingle QuoteShape = "single"
QuoteShapeBatch QuoteShape = "batch"
)
type QuoteIdempotencyService struct{}
func New() *QuoteIdempotencyService {
return &QuoteIdempotencyService{}
}

View File

@@ -0,0 +1,15 @@
package quote_persistence_service
import "strconv"
func cloneBoolPtr(src *bool) *bool {
if src == nil {
return nil
}
value := *src
return &value
}
func itoa(value int) string {
return strconv.Itoa(value)
}

View File

@@ -0,0 +1,33 @@
package quote_persistence_service
import (
"time"
"github.com/tech/sendico/payments/storage/model"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
type StatusInput struct {
Kind quotationv2.QuoteKind
Lifecycle quotationv2.QuoteLifecycle
Executable *bool
BlockReason quotationv2.QuoteBlockReason
}
type PersistInput struct {
OrganizationID bson.ObjectID
QuoteRef string
IdempotencyKey string
Hash string
ExpiresAt time.Time
Intent *model.PaymentIntent
Intents []model.PaymentIntent
Quote *model.PaymentQuoteSnapshot
Quotes []*model.PaymentQuoteSnapshot
Status *StatusInput
Statuses []*StatusInput
}

View File

@@ -0,0 +1,104 @@
package quote_persistence_service
import (
"context"
"strings"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type QuotePersistenceService struct{}
func New() *QuotePersistenceService {
return &QuotePersistenceService{}
}
func (s *QuotePersistenceService) Persist(
ctx context.Context,
quotesStore quotestorage.QuotesStore,
in PersistInput,
) (*model.PaymentQuoteRecord, error) {
if quotesStore == nil {
return nil, merrors.InvalidArgument("quotes store is required")
}
record, err := s.BuildRecord(in)
if err != nil {
return nil, err
}
if err := quotesStore.Create(ctx, record); err != nil {
return nil, err
}
return record, nil
}
func (s *QuotePersistenceService) BuildRecord(in PersistInput) (*model.PaymentQuoteRecord, error) {
if in.OrganizationID == bson.NilObjectID {
return nil, merrors.InvalidArgument("organization_id is required")
}
if strings.TrimSpace(in.QuoteRef) == "" {
return nil, merrors.InvalidArgument("quote_ref is required")
}
if strings.TrimSpace(in.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if strings.TrimSpace(in.Hash) == "" {
return nil, merrors.InvalidArgument("hash is required")
}
if in.ExpiresAt.IsZero() {
return nil, merrors.InvalidArgument("expires_at is required")
}
isSingle := in.Quote != nil
isBatch := len(in.Quotes) > 0
if isSingle == isBatch {
return nil, merrors.InvalidArgument("exactly one quote shape is required")
}
record := &model.PaymentQuoteRecord{
QuoteRef: strings.TrimSpace(in.QuoteRef),
IdempotencyKey: strings.TrimSpace(in.IdempotencyKey),
Hash: strings.TrimSpace(in.Hash),
ExpiresAt: in.ExpiresAt,
}
record.SetID(bson.NewObjectID())
record.SetOrganizationRef(in.OrganizationID)
if isSingle {
if in.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
status, err := mapStatusInput(in.Status)
if err != nil {
return nil, err
}
record.Intent = *in.Intent
record.Quote = in.Quote
record.StatusV2 = status
return record, nil
}
if len(in.Intents) == 0 {
return nil, merrors.InvalidArgument("intents are required")
}
if len(in.Intents) != len(in.Quotes) {
return nil, merrors.InvalidArgument("intents and quotes count mismatch")
}
statuses, err := mapStatusInputs(in.Statuses)
if err != nil {
return nil, err
}
if len(statuses) != len(in.Quotes) {
return nil, merrors.InvalidArgument("statuses and quotes count mismatch")
}
record.Intents = in.Intents
record.Quotes = in.Quotes
record.StatusesV2 = statuses
return record, nil
}

View File

@@ -0,0 +1,176 @@
package quote_persistence_service
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestPersistSingle(t *testing.T) {
svc := New()
store := &fakeQuotesStore{}
orgID := bson.NewObjectID()
trueValue := true
record, err := svc.Persist(context.Background(), store, PersistInput{
OrganizationID: orgID,
QuoteRef: "quote-1",
IdempotencyKey: "idem-1",
Hash: "hash-1",
ExpiresAt: time.Now().Add(time.Minute),
Intent: &model.PaymentIntent{Ref: "intent-1"},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: "quote-1",
},
Status: &StatusInput{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if record == nil {
t.Fatalf("expected record")
}
if store.created == nil {
t.Fatalf("expected record to be created")
}
if store.created.ExecutionNote != "" {
t.Fatalf("expected no legacy execution note, got %q", store.created.ExecutionNote)
}
if store.created.StatusV2 == nil {
t.Fatalf("expected v2 status metadata")
}
if store.created.StatusV2.Kind != model.QuoteKindExecutable {
t.Fatalf("unexpected kind: %q", store.created.StatusV2.Kind)
}
if store.created.StatusV2.Executable == nil || !*store.created.StatusV2.Executable {
t.Fatalf("expected executable=true in persisted status")
}
}
func TestPersistBatch(t *testing.T) {
svc := New()
store := &fakeQuotesStore{}
orgID := bson.NewObjectID()
record, err := svc.Persist(context.Background(), store, PersistInput{
OrganizationID: orgID,
QuoteRef: "quote-batch-1",
IdempotencyKey: "idem-batch-1",
Hash: "hash-batch-1",
ExpiresAt: time.Now().Add(time.Minute),
Intents: []model.PaymentIntent{
{Ref: "i1"},
{Ref: "i2"},
},
Quotes: []*model.PaymentQuoteSnapshot{
{QuoteRef: "q1"},
{QuoteRef: "q2"},
},
Statuses: []*StatusInput{
{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
},
{
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if record == nil {
t.Fatalf("expected record")
}
if len(record.StatusesV2) != 2 {
t.Fatalf("expected 2 statuses, got %d", len(record.StatusesV2))
}
if record.StatusesV2[0].BlockReason != model.QuoteBlockReasonRouteUnavailable {
t.Fatalf("unexpected first status block reason: %q", record.StatusesV2[0].BlockReason)
}
}
func TestPersistValidation(t *testing.T) {
svc := New()
store := &fakeQuotesStore{}
orgID := bson.NewObjectID()
_, err := svc.Persist(context.Background(), nil, PersistInput{})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for nil store, got %v", err)
}
_, err = svc.Persist(context.Background(), store, PersistInput{
OrganizationID: orgID,
QuoteRef: "q",
IdempotencyKey: "i",
Hash: "h",
ExpiresAt: time.Now().Add(time.Minute),
Intent: &model.PaymentIntent{Ref: "intent"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
Status: &StatusInput{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: boolPtr(false),
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for executable=false, got %v", err)
}
_, err = svc.Persist(context.Background(), store, PersistInput{
OrganizationID: orgID,
QuoteRef: "q",
IdempotencyKey: "i",
Hash: "h",
ExpiresAt: time.Now().Add(time.Minute),
Intents: []model.PaymentIntent{
{Ref: "i1"},
},
Quotes: []*model.PaymentQuoteSnapshot{
{QuoteRef: "q1"},
},
Statuses: []*StatusInput{},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for statuses mismatch, got %v", err)
}
}
type fakeQuotesStore struct {
created *model.PaymentQuoteRecord
createErr error
}
func (f *fakeQuotesStore) Create(_ context.Context, quote *model.PaymentQuoteRecord) error {
if f.createErr != nil {
return f.createErr
}
f.created = quote
return nil
}
func (f *fakeQuotesStore) GetByRef(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return nil, quotestorage.ErrQuoteNotFound
}
func (f *fakeQuotesStore) GetByIdempotencyKey(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return nil, quotestorage.ErrQuoteNotFound
}
func boolPtr(v bool) *bool {
return &v
}

View File

@@ -0,0 +1,87 @@
package quote_persistence_service
import (
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
if input == nil {
return nil, merrors.InvalidArgument("status is required")
}
if input.Executable != nil && !*input.Executable {
return nil, merrors.InvalidArgument("status.executable must be true when set")
}
if input.Executable != nil &&
input.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return nil, merrors.InvalidArgument("status.executable and status.block_reason are mutually exclusive")
}
return &model.QuoteStatusV2{
Kind: mapQuoteKind(input.Kind),
Lifecycle: mapQuoteLifecycle(input.Lifecycle),
Executable: cloneBoolPtr(input.Executable),
BlockReason: mapQuoteBlockReason(input.BlockReason),
}, nil
}
func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) {
if len(inputs) == 0 {
return nil, nil
}
result := make([]*model.QuoteStatusV2, 0, len(inputs))
for i, item := range inputs {
mapped, err := mapStatusInput(item)
if err != nil {
return nil, merrors.InvalidArgument("statuses[" + itoa(i) + "]: " + err.Error())
}
result = append(result, mapped)
}
return result, nil
}
func mapQuoteKind(kind quotationv2.QuoteKind) model.QuoteKind {
switch kind {
case quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE:
return model.QuoteKindExecutable
case quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE:
return model.QuoteKindIndicative
default:
return model.QuoteKindUnspecified
}
}
func mapQuoteLifecycle(lifecycle quotationv2.QuoteLifecycle) model.QuoteLifecycle {
switch lifecycle {
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE:
return model.QuoteLifecycleActive
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED:
return model.QuoteLifecycleExpired
default:
return model.QuoteLifecycleUnspecified
}
}
func mapQuoteBlockReason(reason quotationv2.QuoteBlockReason) model.QuoteBlockReason {
switch reason {
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE:
return model.QuoteBlockReasonRouteUnavailable
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED:
return model.QuoteBlockReasonLimitBlocked
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED:
return model.QuoteBlockReasonRiskBlocked
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY:
return model.QuoteBlockReasonInsufficientLiquidity
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE:
return model.QuoteBlockReasonPriceStale
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL:
return model.QuoteBlockReasonAmountTooSmall
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE:
return model.QuoteBlockReasonAmountTooLarge
default:
return model.QuoteBlockReasonUnspecified
}
}

View File

@@ -0,0 +1,167 @@
package quote_request_validator_v2
import (
"errors"
"fmt"
"strings"
"github.com/tech/sendico/pkg/merrors"
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"
)
var (
ErrIdempotencyRequired = errors.New("idempotency key is required")
ErrPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
ErrInitiatorRefRequired = errors.New("initiator_ref is required")
)
type Context struct {
OrganizationRef string
OrganizationID bson.ObjectID
IdempotencyKey string
InitiatorRef string
PreviewOnly bool
IntentCount int
}
type QuoteRequestValidatorV2 struct{}
func New() *QuoteRequestValidatorV2 {
return &QuoteRequestValidatorV2{}
}
func (v *QuoteRequestValidatorV2) ValidateQuotePayment(req *quotationv2.QuotePaymentRequest) (*Context, error) {
if req == nil {
return nil, merrors.InvalidArgument("nil request")
}
orgRef, orgID, err := validateMeta(req.GetMeta())
if err != nil {
return nil, err
}
if err := validateTransferIntent(req.GetIntent(), "intent"); err != nil {
return nil, err
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
previewOnly := req.GetPreviewOnly()
if err := validateIdempotency(idempotencyKey, previewOnly); err != nil {
return nil, err
}
initiatorRef := strings.TrimSpace(req.GetInitiatorRef())
if initiatorRef == "" {
return nil, ErrInitiatorRefRequired
}
return &Context{
OrganizationRef: orgRef,
OrganizationID: orgID,
IdempotencyKey: idempotencyKey,
InitiatorRef: initiatorRef,
PreviewOnly: previewOnly,
IntentCount: 1,
}, nil
}
func (v *QuoteRequestValidatorV2) ValidateQuotePayments(req *quotationv2.QuotePaymentsRequest) (*Context, error) {
if req == nil {
return nil, merrors.InvalidArgument("nil request")
}
orgRef, orgID, err := validateMeta(req.GetMeta())
if err != nil {
return nil, err
}
intents := req.GetIntents()
if len(intents) == 0 {
return nil, merrors.InvalidArgument("intents are required")
}
for i, intent := range intents {
if err := validateTransferIntent(intent, fmt.Sprintf("intents[%d]", i)); err != nil {
return nil, err
}
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
previewOnly := req.GetPreviewOnly()
if err := validateIdempotency(idempotencyKey, previewOnly); err != nil {
return nil, err
}
initiatorRef := strings.TrimSpace(req.GetInitiatorRef())
if initiatorRef == "" {
return nil, ErrInitiatorRefRequired
}
return &Context{
OrganizationRef: orgRef,
OrganizationID: orgID,
IdempotencyKey: idempotencyKey,
InitiatorRef: initiatorRef,
PreviewOnly: previewOnly,
IntentCount: len(intents),
}, nil
}
func validateMeta(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) {
if meta == nil {
return "", bson.NilObjectID, merrors.InvalidArgument("meta is required")
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref is required")
}
orgID, err := bson.ObjectIDFromHex(orgRef)
if err != nil {
return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID")
}
return orgRef, orgID, nil
}
func validateTransferIntent(intent *transferv1.TransferIntent, field string) error {
if intent == nil {
return merrors.InvalidArgument(field + " is required")
}
if !hasEndpointValue(intent.GetSource()) {
return merrors.InvalidArgument(field + ".source is required")
}
if !hasEndpointValue(intent.GetDestination()) {
return merrors.InvalidArgument(field + ".destination is required")
}
if intent.GetAmount() == nil {
return merrors.InvalidArgument(field + ".amount is required")
}
return nil
}
func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool {
if endpoint == nil {
return false
}
if strings.TrimSpace(endpoint.GetPaymentMethodRef()) != "" {
return true
}
if endpoint.GetPaymentMethod() != nil {
return true
}
return strings.TrimSpace(endpoint.GetPayeeRef()) != ""
}
func validateIdempotency(idempotencyKey string, previewOnly bool) error {
if previewOnly && idempotencyKey != "" {
return ErrPreviewWithIdempotency
}
if !previewOnly && idempotencyKey == "" {
return ErrIdempotencyRequired
}
return nil
}

View File

@@ -0,0 +1,251 @@
package quote_request_validator_v2
import (
"errors"
"strings"
"testing"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
)
func TestValidateQuotePayment_Success(t *testing.T) {
validator := New()
orgID := bson.NewObjectID()
req := &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()},
IdempotencyKey: "idem-1",
Intent: validTransferIntent(),
PreviewOnly: false,
InitiatorRef: "actor-1",
}
ctx, err := validator.ValidateQuotePayment(req)
if err != nil {
t.Fatalf("ValidateQuotePayment returned error: %v", err)
}
if ctx == nil {
t.Fatalf("expected validation context")
}
if got, want := ctx.OrganizationRef, orgID.Hex(); got != want {
t.Fatalf("expected organization_ref %q, got %q", want, got)
}
if got, want := ctx.OrganizationID, orgID; got != want {
t.Fatalf("expected organization_id %s, got %s", want.Hex(), got.Hex())
}
if got, want := ctx.IdempotencyKey, "idem-1"; got != want {
t.Fatalf("expected idempotency_key %q, got %q", want, got)
}
if got, want := ctx.InitiatorRef, "actor-1"; got != want {
t.Fatalf("expected initiator_ref %q, got %q", want, got)
}
if got, want := ctx.IntentCount, 1; got != want {
t.Fatalf("expected intent_count %d, got %d", want, got)
}
}
func TestValidateQuotePayment_Rules(t *testing.T) {
validator := New()
orgHex := bson.NewObjectID().Hex()
testCases := []struct {
name string
req *quotationv2.QuotePaymentRequest
checkErr func(error) bool
}{
{
name: "idempotency required for non-preview",
req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
Intent: validTransferIntent(),
PreviewOnly: false,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool { return errors.Is(err, ErrIdempotencyRequired) },
},
{
name: "preview must not include idempotency",
req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intent: validTransferIntent(),
PreviewOnly: true,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool { return errors.Is(err, ErrPreviewWithIdempotency) },
},
{
name: "initiator ref required",
req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intent: validTransferIntent(),
PreviewOnly: false,
},
checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) },
},
{
name: "invalid org ref",
req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "bad-org"},
IdempotencyKey: "idem-1",
Intent: validTransferIntent(),
PreviewOnly: false,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool {
return err != nil && strings.Contains(err.Error(), "organization_ref must be a valid objectID")
},
},
{
name: "source required",
req: &quotationv2.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intent: &transferv1.TransferIntent{
Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
},
PreviewOnly: false,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") },
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := validator.ValidateQuotePayment(tc.req)
if !tc.checkErr(err) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateQuotePayments_Success(t *testing.T) {
validator := New()
orgID := bson.NewObjectID()
req := &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()},
Intents: []*transferv1.TransferIntent{validTransferIntent(), validTransferIntent()},
PreviewOnly: true,
InitiatorRef: "actor-1",
}
ctx, err := validator.ValidateQuotePayments(req)
if err != nil {
t.Fatalf("ValidateQuotePayments returned error: %v", err)
}
if ctx == nil {
t.Fatalf("expected validation context")
}
if got, want := ctx.IntentCount, 2; got != want {
t.Fatalf("expected intent_count %d, got %d", want, got)
}
if got, want := ctx.PreviewOnly, true; got != want {
t.Fatalf("expected preview_only %v, got %v", want, got)
}
if got, want := ctx.IdempotencyKey, ""; got != want {
t.Fatalf("expected idempotency_key %q, got %q", want, got)
}
}
func TestValidateQuotePayments_Rules(t *testing.T) {
validator := New()
orgHex := bson.NewObjectID().Hex()
testCases := []struct {
name string
req *quotationv2.QuotePaymentsRequest
checkErr func(error) bool
}{
{
name: "intents required",
req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
PreviewOnly: false,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intents are required") },
},
{
name: "idempotency required for non-preview",
req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
Intents: []*transferv1.TransferIntent{validTransferIntent()},
PreviewOnly: false,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool { return errors.Is(err, ErrIdempotencyRequired) },
},
{
name: "preview must not include idempotency",
req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intents: []*transferv1.TransferIntent{validTransferIntent()},
PreviewOnly: true,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool { return errors.Is(err, ErrPreviewWithIdempotency) },
},
{
name: "initiator ref required",
req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
Intents: []*transferv1.TransferIntent{validTransferIntent()},
PreviewOnly: true,
},
checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) },
},
{
name: "indexed intent validation",
req: &quotationv2.QuotePaymentsRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex},
IdempotencyKey: "idem-1",
Intents: []*transferv1.TransferIntent{
{
Source: endpointWithMethodRef("pm-src"),
Destination: endpointWithMethodRef("pm-dst"),
},
},
PreviewOnly: false,
InitiatorRef: "actor-1",
},
checkErr: func(err error) bool {
return err != nil && strings.Contains(err.Error(), "intents[0].amount is required")
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := validator.ValidateQuotePayments(tc.req)
if !tc.checkErr(err) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func validTransferIntent() *transferv1.TransferIntent {
return &transferv1.TransferIntent{
Source: endpointWithMethodRef("pm-src"),
Destination: endpointWithMethodRef("pm-dst"),
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
}
}
func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint {
return &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{
PaymentMethodRef: methodRef,
},
}
}

View File

@@ -0,0 +1,140 @@
package quote_response_mapper_v2
import (
"strings"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"google.golang.org/protobuf/proto"
)
func cloneMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.TrimSpace(src.GetCurrency()),
}
}
func cloneFeeLines(src []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine {
if len(src) == 0 {
return nil
}
result := make([]*feesv1.DerivedPostingLine, 0, len(src))
for _, line := range src {
if line == nil {
result = append(result, nil)
continue
}
cloned, ok := proto.Clone(line).(*feesv1.DerivedPostingLine)
if !ok {
continue
}
result = append(result, cloned)
}
if len(result) == 0 {
return nil
}
return result
}
func cloneFeeRules(src []*feesv1.AppliedRule) []*feesv1.AppliedRule {
if len(src) == 0 {
return nil
}
result := make([]*feesv1.AppliedRule, 0, len(src))
for _, rule := range src {
if rule == nil {
result = append(result, nil)
continue
}
cloned, ok := proto.Clone(rule).(*feesv1.AppliedRule)
if !ok {
continue
}
result = append(result, cloned)
}
if len(result) == 0 {
return nil
}
return result
}
func cloneFXQuote(src *oraclev1.Quote) *oraclev1.Quote {
if src == nil {
return nil
}
cloned, ok := proto.Clone(src).(*oraclev1.Quote)
if !ok {
return nil
}
return cloned
}
func cloneRoute(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
if src == nil {
return nil
}
result := &quotationv2.RouteSpecification{
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
for _, hop := range hops {
if hop == nil {
continue
}
result.Hops = append(result.Hops, &quotationv2.RouteHop{
Index: hop.GetIndex(),
Rail: strings.TrimSpace(hop.GetRail()),
Gateway: strings.TrimSpace(hop.GetGateway()),
InstanceId: strings.TrimSpace(hop.GetInstanceId()),
Network: strings.TrimSpace(hop.GetNetwork()),
Role: hop.GetRole(),
})
}
if len(result.Hops) == 0 {
result.Hops = nil
}
}
return result
}
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil
}
result := &quotationv2.ExecutionConditions{
Readiness: src.GetReadiness(),
BatchingEligible: src.GetBatchingEligible(),
PrefundingRequired: src.GetPrefundingRequired(),
PrefundingCostIncluded: src.GetPrefundingCostIncluded(),
LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(),
LatencyHint: strings.TrimSpace(src.GetLatencyHint()),
}
if assumptions := src.GetAssumptions(); len(assumptions) > 0 {
result.Assumptions = make([]string, 0, len(assumptions))
for _, assumption := range assumptions {
trimmed := strings.TrimSpace(assumption)
if trimmed == "" {
continue
}
result.Assumptions = append(result.Assumptions, trimmed)
}
if len(result.Assumptions) == 0 {
result.Assumptions = nil
}
}
return result
}

View File

@@ -0,0 +1,50 @@
package quote_response_mapper_v2
import (
"time"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
type QuoteMeta struct {
ID string
CreatedAt time.Time
UpdatedAt time.Time
}
type CanonicalQuote struct {
QuoteRef string
DebitAmount *moneyv1.Money
CreditAmount *moneyv1.Money
TotalCost *moneyv1.Money
FeeLines []*feesv1.DerivedPostingLine
FeeRules []*feesv1.AppliedRule
FXQuote *oraclev1.Quote
Route *quotationv2.RouteSpecification
Conditions *quotationv2.ExecutionConditions
ExpiresAt time.Time
PricedAt time.Time
}
type QuoteStatus struct {
Kind quotationv2.QuoteKind
Lifecycle quotationv2.QuoteLifecycle
Executable *bool
BlockReason quotationv2.QuoteBlockReason
}
type MapInput struct {
Meta QuoteMeta
Quote CanonicalQuote
Status QuoteStatus
}
type MapOutput struct {
Quote *quotationv2.PaymentQuote
HasExecutionStatus bool
Executable bool
BlockReason quotationv2.QuoteBlockReason
}

View File

@@ -0,0 +1,72 @@
package quote_response_mapper_v2
import (
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
type executionDecision struct {
hasStatus bool
executable bool
blockReason quotationv2.QuoteBlockReason
}
func validateStatusInvariants(status QuoteStatus) (executionDecision, error) {
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.kind is required")
}
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.lifecycle is required")
}
hasExecutable := status.Executable != nil
hasBlockReason := status.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE {
if hasExecutable || hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("execution_status must be unset for indicative quote")
}
return executionDecision{}, nil
}
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED {
if hasExecutable || hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("execution_status must be unset for expired quote")
}
return executionDecision{}, nil
}
if status.Kind != quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE ||
status.Lifecycle != quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE {
if hasExecutable || hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("execution_status is only valid for executable active quote")
}
return executionDecision{}, nil
}
if hasExecutable == hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("exactly one execution status is required")
}
if hasExecutable && !status.ExecutableValue() {
return executionDecision{}, merrors.InvalidArgument("execution_status.executable must be true")
}
if hasExecutable {
return executionDecision{
hasStatus: true,
executable: true,
}, nil
}
return executionDecision{
hasStatus: true,
executable: false,
blockReason: status.BlockReason,
}, nil
}
func (s QuoteStatus) ExecutableValue() bool {
if s.Executable == nil {
return false
}
return *s.Executable
}

View File

@@ -0,0 +1,76 @@
package quote_response_mapper_v2
import (
"strings"
"time"
storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"google.golang.org/protobuf/types/known/timestamppb"
)
type QuoteResponseMapperV2 struct{}
func New() *QuoteResponseMapperV2 {
return &QuoteResponseMapperV2{}
}
func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
decision, err := validateStatusInvariants(in.Status)
if err != nil {
return nil, err
}
result := &quotationv2.PaymentQuote{
Storable: mapStorable(in.Meta),
Kind: in.Status.Kind,
Lifecycle: in.Status.Lifecycle,
DebitAmount: cloneMoney(in.Quote.DebitAmount),
CreditAmount: cloneMoney(in.Quote.CreditAmount),
TotalCost: cloneMoney(in.Quote.TotalCost),
FeeLines: cloneFeeLines(in.Quote.FeeLines),
FeeRules: cloneFeeRules(in.Quote.FeeRules),
FxQuote: cloneFXQuote(in.Quote.FXQuote),
Route: cloneRoute(in.Quote.Route),
ExecutionConditions: cloneExecutionConditions(in.Quote.Conditions),
QuoteRef: strings.TrimSpace(in.Quote.QuoteRef),
ExpiresAt: tsOrNil(in.Quote.ExpiresAt),
PricedAt: tsOrNil(in.Quote.PricedAt),
}
if decision.hasStatus {
if decision.executable {
result.ExecutionStatus = &quotationv2.PaymentQuote_Executable{Executable: true}
} else {
result.ExecutionStatus = &quotationv2.PaymentQuote_BlockReason{
BlockReason: decision.blockReason,
}
}
}
return &MapOutput{
Quote: result,
HasExecutionStatus: decision.hasStatus,
Executable: decision.executable,
BlockReason: decision.blockReason,
}, nil
}
func mapStorable(meta QuoteMeta) *storablev1.Storable {
id := strings.TrimSpace(meta.ID)
if id == "" && meta.CreatedAt.IsZero() && meta.UpdatedAt.IsZero() {
return nil
}
return &storablev1.Storable{
Id: id,
CreatedAt: tsOrNil(meta.CreatedAt),
UpdatedAt: tsOrNil(meta.UpdatedAt),
}
}
func tsOrNil(value time.Time) *timestamppb.Timestamp {
if value.IsZero() {
return nil
}
return timestamppb.New(value)
}

View File

@@ -0,0 +1,207 @@
package quote_response_mapper_v2
import (
"errors"
"testing"
"time"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func TestMap_ExecutableActiveQuote(t *testing.T) {
mapper := New()
trueValue := true
createdAt := time.Unix(100, 0)
updatedAt := time.Unix(120, 0)
expiresAt := time.Unix(200, 0)
pricedAt := time.Unix(150, 0)
out, err := mapper.Map(MapInput{
Meta: QuoteMeta{
ID: "rec-1",
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
Quote: CanonicalQuote{
QuoteRef: "q-1",
DebitAmount: &moneyv1.Money{
Amount: "10",
Currency: "USD",
},
CreditAmount: &moneyv1.Money{
Amount: "9",
Currency: "EUR",
},
TotalCost: &moneyv1.Money{
Amount: "10.2",
Currency: "USD",
},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Provider: "monetix",
PayoutMethod: "CARD",
SettlementAsset: "USD",
SettlementModel: "FIX_SOURCE",
},
Conditions: &quotationv2.ExecutionConditions{
Readiness: quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY,
BatchingEligible: true,
PrefundingRequired: false,
LiquidityCheckRequiredAtExecution: true,
},
ExpiresAt: expiresAt,
PricedAt: pricedAt,
},
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil || out.Quote == nil {
t.Fatalf("expected mapped quote")
}
if !out.HasExecutionStatus || !out.Executable {
t.Fatalf("expected executable status")
}
if !out.Quote.GetExecutable() {
t.Fatalf("expected proto executable=true")
}
if out.Quote.GetBlockReason() != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason")
}
if out.Quote.GetStorable().GetId() != "rec-1" {
t.Fatalf("expected storable id rec-1, got %q", out.Quote.GetStorable().GetId())
}
if got := out.Quote.GetStorable().GetCreatedAt().AsTime(); !got.Equal(createdAt) {
t.Fatalf("unexpected created_at: %v", got)
}
if got := out.Quote.GetStorable().GetUpdatedAt().AsTime(); !got.Equal(updatedAt) {
t.Fatalf("unexpected updated_at: %v", got)
}
if got := out.Quote.GetExpiresAt().AsTime(); !got.Equal(expiresAt) {
t.Fatalf("unexpected expires_at: %v", got)
}
if got := out.Quote.GetPricedAt().AsTime(); !got.Equal(pricedAt) {
t.Fatalf("unexpected priced_at: %v", got)
}
if got, want := out.Quote.GetRoute().GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected route provider: 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)
}
}
func TestMap_BlockedExecutableQuote(t *testing.T) {
mapper := New()
out, err := mapper.Map(MapInput{
Quote: CanonicalQuote{
QuoteRef: "q-2",
},
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil || out.Quote == nil {
t.Fatalf("expected mapped quote")
}
if !out.HasExecutionStatus || out.Executable {
t.Fatalf("expected blocked status")
}
if got := out.Quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE {
t.Fatalf("unexpected block reason: %s", got.String())
}
}
func TestMap_IndicativeAndExpiredMustHaveNoExecutionStatus(t *testing.T) {
mapper := New()
trueValue := true
_, err := mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg for indicative with execution status, got %v", err)
}
_, err = mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED,
Executable: &trueValue,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg for expired with execution status, got %v", err)
}
out, err := mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.HasExecutionStatus {
t.Fatalf("expected unset execution status")
}
if out.Quote.GetExecutionStatus() != nil {
t.Fatalf("expected no execution_status oneof")
}
}
func TestMap_ExecutableActiveRequiresExactlyOneExecutionStatus(t *testing.T) {
mapper := New()
trueValue := true
_, err := mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg when execution status is missing, got %v", err)
}
_, err = mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg when both executable and block_reason are set, got %v", err)
}
falseValue := false
_, err = mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &falseValue,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg for executable=false, got %v", err)
}
}

View File

@@ -78,7 +78,7 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
}
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
if errors.Is(err, quotestorage.ErrQuoteNotFound) {
return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
}
return nil, nil, nil, err

View File

@@ -0,0 +1,150 @@
package transfer_intent_hydrator
import (
"context"
"strings"
"github.com/tech/sendico/pkg/merrors"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
)
func (h *TransferIntentHydrator) hydrateEndpoint(
ctx context.Context,
organizationRef string,
endpoint *endpointv1.PaymentEndpoint,
field string,
role methodsv1.PrivateEndpoint,
) (QuoteEndpoint, error) {
if endpoint == nil {
return QuoteEndpoint{}, merrors.InvalidArgument(field + " is required")
}
if methodRef := strings.TrimSpace(endpoint.GetPaymentMethodRef()); methodRef != "" {
resolved, resolvedMethodRef, err := h.resolvePrivate(ctx, organizationRef, field+".payment_method_ref", role, privateSelector{
paymentMethodRef: methodRef,
})
if err != nil {
return QuoteEndpoint{}, err
}
if resolvedMethodRef != "" {
resolved.PaymentMethodRef = resolvedMethodRef
} else {
resolved.PaymentMethodRef = methodRef
}
return resolved, nil
}
if method := endpoint.GetPaymentMethod(); method != nil {
if method.GetType() != endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_ACCOUNT {
return hydrateFromPaymentMethod(method, field)
}
recipientRef := strings.TrimSpace(method.GetRecipientRef())
if recipientRef == "" {
return QuoteEndpoint{}, merrors.InvalidArgument(field + ".payment_method.recipient_ref is required for account payment method")
}
resolved, resolvedMethodRef, err := h.resolvePrivate(ctx, organizationRef, field+".payment_method", role, privateSelector{
payeeRef: recipientRef,
})
if err != nil {
return QuoteEndpoint{}, err
}
if resolvedMethodRef != "" {
resolved.PaymentMethodRef = resolvedMethodRef
}
return resolved, nil
}
if payeeRef := strings.TrimSpace(endpoint.GetPayeeRef()); payeeRef != "" {
resolved, resolvedMethodRef, err := h.resolvePrivate(ctx, organizationRef, field+".payee_ref", role, privateSelector{
payeeRef: payeeRef,
})
if err != nil {
return QuoteEndpoint{}, err
}
resolved.PayeeRef = payeeRef
resolved.PaymentMethodRef = resolvedMethodRef
return resolved, nil
}
return QuoteEndpoint{}, merrors.InvalidArgument(field + " must include payment_method_ref, payment_method, or payee_ref")
}
type privateSelector struct {
paymentMethodRef string
payeeRef string
}
func (h *TransferIntentHydrator) resolvePrivate(
ctx context.Context,
organizationRef string,
field string,
role methodsv1.PrivateEndpoint,
selector privateSelector,
) (QuoteEndpoint, string, error) {
if h.methodsClient == nil {
return QuoteEndpoint{}, "", ErrPaymentMethodsClientRequired
}
req := &methodsv1.GetPaymentMethodPrivateRequest{
OrganizationRef: strings.TrimSpace(organizationRef),
Endpoint: role,
}
if selector.paymentMethodRef != "" {
req.Selector = &methodsv1.GetPaymentMethodPrivateRequest_PaymentMethodRef{
PaymentMethodRef: strings.TrimSpace(selector.paymentMethodRef),
}
}
if selector.payeeRef != "" {
req.Selector = &methodsv1.GetPaymentMethodPrivateRequest_PayeeRef{
PayeeRef: strings.TrimSpace(selector.payeeRef),
}
}
resp, err := h.methodsClient.GetPaymentMethodPrivate(ctx, req)
if err != nil {
return QuoteEndpoint{}, "", err
}
record := resp.GetPaymentMethodRecord()
if record == nil || record.GetPaymentMethod() == nil {
return QuoteEndpoint{}, "", merrors.InvalidArgument(field + " not found")
}
resolved, err := hydrateFromPaymentMethod(record.GetPaymentMethod(), field)
if err != nil {
return QuoteEndpoint{}, "", err
}
methodRef := strings.TrimSpace(record.GetPermissionBound().GetStorable().GetId())
return resolved, methodRef, nil
}
/*
Processing Classes
QuoteRequestValidatorV2
Validates meta, idempotency, preview rules, initiator_ref, non-empty intents.
TransferIntentHydrator
Converts transferv1.TransferIntent to canonical sharedv1.PaymentIntent.
Resolves endpoint refs (payment_method_ref, payee_ref) using a new methods dependency.
Applies defaults for kind, settlement_mode, settlement_currency, attributes.
QuoteIdempotencyService
Computes request fingerprint for v2 requests.
Reuse/create logic against QuotesStore (same pattern as handlers_commands.go).
QuoteComputationService
Calls existing core for quote and plan building.
Returns quote + expiry + optional plan.
QuoteExecutabilityClassifier
Converts plan/build errors to QuoteBlockReason.
Produces execution_status (executable=true or block_reason).
QuotePersistenceService
Persists quote record with v2 status metadata.
Keeps legacy ExecutionNote for backward compatibility.
QuoteResponseMapperV2
Maps canonical quote + status to quotationv2.PaymentQuote.
Enforces your lifecycle/execution invariants.
BatchQuoteProcessorV2
Iterates single-intent processor with per-item idempotency derivation.
Returns QuotePaymentsResponse without aggregate.
*/

View File

@@ -0,0 +1,7 @@
package transfer_intent_hydrator
import "errors"
var (
ErrPaymentMethodsClientRequired = errors.New("payment methods client is required to resolve endpoint references")
)

View File

@@ -0,0 +1,40 @@
package transfer_intent_hydrator
import (
"strconv"
"strings"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
func decodeMethodData(data []byte, dst any, field string) error {
if len(data) == 0 {
return merrors.InvalidArgument(field + ".data is required")
}
if err := bson.Unmarshal(data, dst); err != nil {
return merrors.InvalidArgument(field + ".data is invalid")
}
return nil
}
func parseUint32(value string, field string) (uint32, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, nil
}
num, err := strconv.ParseUint(trimmed, 10, 32)
if err != nil {
return 0, merrors.InvalidArgument(field + " must be numeric")
}
return uint32(num), nil
}
func inferKind(destination QuoteEndpoint) QuoteIntentKind {
switch destination.Type {
case QuoteEndpointTypeExternalChain, QuoteEndpointTypeCard:
return QuoteIntentKindPayout
default:
return QuoteIntentKindInternalTransfer
}
}

View File

@@ -0,0 +1,148 @@
package transfer_intent_hydrator
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc"
)
type HydrateOneInput struct {
OrganizationRef string
InitiatorRef string
Intent *transferv1.TransferIntent
}
type HydrateManyInput struct {
OrganizationRef string
InitiatorRef string
Intents []*transferv1.TransferIntent
}
type PaymentMethodsClient interface {
GetPaymentMethodPrivate(ctx context.Context, in *methodsv1.GetPaymentMethodPrivateRequest, opts ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error)
}
type Option func(*TransferIntentHydrator)
type TransferIntentHydrator struct {
methodsClient PaymentMethodsClient
newRef func() string
}
func New(methodsClient PaymentMethodsClient, opts ...Option) *TransferIntentHydrator {
h := &TransferIntentHydrator{
methodsClient: methodsClient,
newRef: func() string {
return bson.NewObjectID().Hex()
},
}
for _, opt := range opts {
if opt != nil {
opt(h)
}
}
if h.newRef == nil {
h.newRef = func() string { return bson.NewObjectID().Hex() }
}
return h
}
func WithRefFactory(newRef func() string) Option {
return func(h *TransferIntentHydrator) {
if newRef != nil {
h.newRef = newRef
}
}
}
func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneInput) (*QuoteIntent, error) {
if strings.TrimSpace(in.OrganizationRef) == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if strings.TrimSpace(in.InitiatorRef) == "" {
return nil, merrors.InvalidArgument("initiator_ref is required")
}
if in.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
if in.Intent.GetAmount() == nil {
return nil, merrors.InvalidArgument("intent.amount is required")
}
source, err := h.hydrateEndpoint(
ctx,
in.OrganizationRef,
in.Intent.GetSource(),
"intent.source",
methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_SOURCE,
)
if err != nil {
return nil, err
}
destination, err := h.hydrateEndpoint(
ctx,
in.OrganizationRef,
in.Intent.GetDestination(),
"intent.destination",
methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_DESTINATION,
)
if err != nil {
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")
}
intent := &QuoteIntent{
Ref: h.newRef(),
Kind: inferKind(destination),
Source: source,
Destination: destination,
Amount: amount,
Comment: strings.TrimSpace(in.Intent.GetComment()),
SettlementMode: QuoteSettlementModeUnspecified,
SettlementCurrency: amount.Currency,
RequiresFX: false,
Attributes: map[string]string{
"initiator_ref": strings.TrimSpace(in.InitiatorRef),
},
}
if intent.Comment != "" {
intent.Attributes["comment"] = intent.Comment
}
return intent, nil
}
func (h *TransferIntentHydrator) HydrateMany(ctx context.Context, in HydrateManyInput) ([]*QuoteIntent, error) {
if len(in.Intents) == 0 {
return nil, merrors.InvalidArgument("intents are required")
}
out := make([]*QuoteIntent, 0, len(in.Intents))
for i, intent := range in.Intents {
item, err := h.HydrateOne(ctx, HydrateOneInput{
OrganizationRef: in.OrganizationRef,
InitiatorRef: in.InitiatorRef,
Intent: intent,
})
if err != nil {
return nil, fmt.Errorf("intents[%d]: %w", i, err)
}
out = append(out, item)
}
return out, nil
}

View File

@@ -0,0 +1,161 @@
package transfer_intent_hydrator
import (
"fmt"
"strings"
chainpkg "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
)
func hydrateFromPaymentMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) {
if method == nil {
return QuoteEndpoint{}, merrors.InvalidArgument(field + " is required")
}
switch method.GetType() {
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET:
return hydrateWalletMethod(method, field)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS:
return hydrateCryptoAddressMethod(method, field)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD:
return hydrateCardMethod(method, field)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN:
return hydrateCardTokenMethod(method, field)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER:
return hydrateLedgerMethod(method, field)
default:
return QuoteEndpoint{}, merrors.InvalidArgument(
fmt.Sprintf("%s uses unsupported payment method type: %s", field, method.GetType().String()),
)
}
}
func hydrateWalletMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) {
var data pkgmodel.WalletPaymentData
if err := decodeMethodData(method.GetData(), &data, field); err != nil {
return QuoteEndpoint{}, err
}
ref := strings.TrimSpace(data.WalletID)
if ref == "" {
return QuoteEndpoint{}, merrors.InvalidArgument(field + ".wallet_id is required")
}
return QuoteEndpoint{
Type: QuoteEndpointTypeManagedWallet,
ManagedWallet: &QuoteManagedWalletEndpoint{
ManagedWalletRef: ref,
},
}, nil
}
func hydrateCryptoAddressMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) {
var data pkgmodel.CryptoAddressPaymentData
if err := decodeMethodData(method.GetData(), &data, field); err != nil {
return QuoteEndpoint{}, err
}
addr := strings.TrimSpace(data.Address)
if addr == "" {
return QuoteEndpoint{}, merrors.InvalidArgument(field + ".address is required")
}
asset := &paymenttypes.Asset{
TokenSymbol: strings.ToUpper(strings.TrimSpace(string(data.Currency))),
}
network := chainpkg.NetworkFromString(data.Network)
if networkAlias := strings.TrimSpace(chainpkg.NetworkAlias(network)); networkAlias != "" && networkAlias != "UNSPECIFIED" {
asset.Chain = networkAlias
}
if data.DestinationTag != nil {
memo := strings.TrimSpace(*data.DestinationTag)
return QuoteEndpoint{
Type: QuoteEndpointTypeExternalChain,
ExternalChain: &QuoteExternalChainEndpoint{
Asset: asset,
Address: addr,
Memo: memo,
},
}, nil
}
return QuoteEndpoint{
Type: QuoteEndpointTypeExternalChain,
ExternalChain: &QuoteExternalChainEndpoint{
Asset: asset,
Address: addr,
},
}, nil
}
func hydrateCardMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) {
var data pkgmodel.CardPaymentData
if err := decodeMethodData(method.GetData(), &data, field); err != nil {
return QuoteEndpoint{}, err
}
expMonth, err := parseUint32(data.ExpMonth, field+".exp_month")
if err != nil {
return QuoteEndpoint{}, err
}
expYear, err := parseUint32(data.ExpYear, field+".exp_year")
if err != nil {
return QuoteEndpoint{}, err
}
return QuoteEndpoint{
Type: QuoteEndpointTypeCard,
Card: &QuoteCardEndpoint{
Pan: strings.TrimSpace(data.Pan),
Cardholder: strings.TrimSpace(data.FirstName),
CardholderSurname: strings.TrimSpace(data.LastName),
ExpMonth: expMonth,
ExpYear: expYear,
Country: strings.TrimSpace(data.Country),
},
}, nil
}
func hydrateCardTokenMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) {
var data pkgmodel.TokenPaymentData
if err := decodeMethodData(method.GetData(), &data, field); err != nil {
return QuoteEndpoint{}, err
}
expMonth, err := parseUint32(data.ExpMonth, field+".exp_month")
if err != nil {
return QuoteEndpoint{}, err
}
expYear, err := parseUint32(data.ExpYear, field+".exp_year")
if err != nil {
return QuoteEndpoint{}, err
}
return QuoteEndpoint{
Type: QuoteEndpointTypeCard,
Card: &QuoteCardEndpoint{
Token: strings.TrimSpace(data.Token),
Cardholder: strings.TrimSpace(data.CardholderName),
ExpMonth: expMonth,
ExpYear: expYear,
Country: strings.TrimSpace(data.Country),
MaskedPan: strings.TrimSpace(data.Last4),
},
}, nil
}
func hydrateLedgerMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) {
type ledgerData struct {
LedgerAccountRef string `bson:"ledgerAccountRef"`
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
}
var data ledgerData
if err := decodeMethodData(method.GetData(), &data, field); err != nil {
return QuoteEndpoint{}, err
}
ref := strings.TrimSpace(data.LedgerAccountRef)
if ref == "" {
return QuoteEndpoint{}, merrors.InvalidArgument(field + ".ledger_account_ref is required")
}
return QuoteEndpoint{
Type: QuoteEndpointTypeLedger,
Ledger: &QuoteLedgerEndpoint{
LedgerAccountRef: ref,
ContraLedgerAccountRef: strings.TrimSpace(data.ContraLedgerAccountRef),
},
}, nil
}

View File

@@ -0,0 +1,81 @@
package transfer_intent_hydrator
import paymenttypes "github.com/tech/sendico/pkg/payments/types"
type QuoteIntentKind string
const (
QuoteIntentKindUnspecified QuoteIntentKind = "unspecified"
QuoteIntentKindPayout QuoteIntentKind = "payout"
QuoteIntentKindInternalTransfer QuoteIntentKind = "internal_transfer"
QuoteIntentKindFXConversion QuoteIntentKind = "fx_conversion"
)
type QuoteSettlementMode string
const (
QuoteSettlementModeUnspecified QuoteSettlementMode = "unspecified"
QuoteSettlementModeFixSource QuoteSettlementMode = "fix_source"
QuoteSettlementModeFixReceived QuoteSettlementMode = "fix_received"
)
type QuoteEndpointType string
const (
QuoteEndpointTypeUnspecified QuoteEndpointType = "unspecified"
QuoteEndpointTypeLedger QuoteEndpointType = "ledger"
QuoteEndpointTypeManagedWallet QuoteEndpointType = "managed_wallet"
QuoteEndpointTypeExternalChain QuoteEndpointType = "external_chain"
QuoteEndpointTypeCard QuoteEndpointType = "card"
)
type QuoteLedgerEndpoint struct {
LedgerAccountRef string
ContraLedgerAccountRef string
}
type QuoteManagedWalletEndpoint struct {
ManagedWalletRef string
Asset *paymenttypes.Asset
}
type QuoteExternalChainEndpoint struct {
Asset *paymenttypes.Asset
Address string
Memo string
}
type QuoteCardEndpoint struct {
Pan string
Token string
Cardholder string
CardholderSurname string
ExpMonth uint32
ExpYear uint32
Country string
MaskedPan string
}
type QuoteEndpoint struct {
Type QuoteEndpointType
PaymentMethodRef string
PayeeRef string
Ledger *QuoteLedgerEndpoint
ManagedWallet *QuoteManagedWalletEndpoint
ExternalChain *QuoteExternalChainEndpoint
Card *QuoteCardEndpoint
}
type QuoteIntent struct {
Ref string
Kind QuoteIntentKind
Source QuoteEndpoint
Destination QuoteEndpoint
Amount *paymenttypes.Money
Comment string
SettlementMode QuoteSettlementMode
SettlementCurrency string
RequiresFX bool
Attributes map[string]string
}

View File

@@ -0,0 +1,454 @@
package transfer_intent_hydrator
import (
"context"
"errors"
"strings"
"testing"
pkgmodel "github.com/tech/sendico/pkg/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc"
)
func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) {
h := New(nil, WithRefFactory(func() string { return "q-intent-1" }))
tag := "12345"
intent := &transferv1.TransferIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-src-1",
}),
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS,
Data: mustMarshalBSON(t, pkgmodel.CryptoAddressPaymentData{
Currency: pkgmodel.CurrencyUSDT,
Address: "TXYZ",
Network: "TRON_MAINNET",
DestinationTag: &tag,
}),
},
},
},
Amount: newMoney("10.25", "USDT"),
Comment: "transfer note",
}
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: bson.NewObjectID().Hex(),
InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent,
})
if err != nil {
t.Fatalf("HydrateOne returned error: %v", err)
}
if got == nil {
t.Fatalf("expected hydrated intent")
}
if got.Ref != "q-intent-1" {
t.Fatalf("expected ref q-intent-1, got %q", got.Ref)
}
if got.Kind != QuoteIntentKindPayout {
t.Fatalf("expected payout kind, got %s", got.Kind)
}
if got.Source.Type != QuoteEndpointTypeManagedWallet {
t.Fatalf("expected managed wallet source, got %s", got.Source.Type)
}
if got.Source.ManagedWallet == nil || got.Source.ManagedWallet.ManagedWalletRef != "mw-src-1" {
t.Fatalf("unexpected managed wallet source: %#v", got.Source.ManagedWallet)
}
if got.Destination.Type != QuoteEndpointTypeExternalChain {
t.Fatalf("expected external chain destination, got %s", got.Destination.Type)
}
if got.Destination.ExternalChain == nil {
t.Fatalf("expected external chain payload")
}
if got.Destination.ExternalChain.Asset == nil || got.Destination.ExternalChain.Asset.Chain != "TRON_MAINNET" {
t.Fatalf("expected TRON_MAINNET chain, got %#v", got.Destination.ExternalChain.Asset)
}
if got.Destination.ExternalChain.Memo != tag {
t.Fatalf("expected destination memo %q, got %q", tag, got.Destination.ExternalChain.Memo)
}
if got.SettlementCurrency != "USDT" {
t.Fatalf("expected settlement currency USDT, got %q", got.SettlementCurrency)
}
if got.Amount == nil || got.Amount.Amount != "10.25" {
t.Fatalf("unexpected amount: %#v", got.Amount)
}
}
func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) {
orgRef := bson.NewObjectID().Hex()
methodRef := bson.NewObjectID().Hex()
resolvedMethodRef := bson.NewObjectID().Hex()
var getReq *methodsv1.GetPaymentMethodPrivateRequest
h := New(&fakeMethodsClient{
getPaymentMethodPrivateFn: func(
_ context.Context,
req *methodsv1.GetPaymentMethodPrivateRequest,
_ ...grpc.CallOption,
) (*methodsv1.GetPaymentMethodPrivateResponse, error) {
getReq = req
return &methodsv1.GetPaymentMethodPrivateResponse{
PaymentMethodRecord: &endpointv1.PaymentMethodRecord{
PermissionBound: &pboundv1.PermissionBound{
Storable: &storablev1.Storable{Id: resolvedMethodRef},
},
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-source-ref",
}),
},
},
}, nil
},
})
intent := &transferv1.TransferIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: methodRef},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{
Pan: "4111111111111111",
ExpMonth: "12",
ExpYear: "2030",
Country: "US",
}),
},
},
},
Amount: newMoney("1", "USD"),
}
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: orgRef,
InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent,
})
if err != nil {
t.Fatalf("HydrateOne returned error: %v", err)
}
if getReq == nil {
t.Fatalf("expected GetPaymentMethodPrivate call")
}
if getReq.GetOrganizationRef() != orgRef {
t.Fatalf("unexpected organization_ref in request: %#v", getReq)
}
if getReq.GetEndpoint() != methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_SOURCE {
t.Fatalf("expected source endpoint role, got %s", getReq.GetEndpoint().String())
}
if getReq.GetPaymentMethodRef() != methodRef {
t.Fatalf("unexpected payment_method_ref in request: %#v", getReq)
}
if got.Source.PaymentMethodRef != resolvedMethodRef {
t.Fatalf("expected resolved payment_method_ref %q, got %q", resolvedMethodRef, got.Source.PaymentMethodRef)
}
if got.Source.ManagedWallet == nil || got.Source.ManagedWallet.ManagedWalletRef != "mw-source-ref" {
t.Fatalf("unexpected resolved source: %#v", got.Source)
}
}
func TestHydrateOne_ResolvesPayeeRefViaPrivateMethod(t *testing.T) {
orgRef := bson.NewObjectID().Hex()
payeeRef := bson.NewObjectID().Hex()
mainMethodRef := bson.NewObjectID().Hex()
var getReq *methodsv1.GetPaymentMethodPrivateRequest
h := New(&fakeMethodsClient{
getPaymentMethodPrivateFn: func(
_ context.Context,
req *methodsv1.GetPaymentMethodPrivateRequest,
_ ...grpc.CallOption,
) (*methodsv1.GetPaymentMethodPrivateResponse, error) {
getReq = req
return &methodsv1.GetPaymentMethodPrivateResponse{
PaymentMethodRecord: &endpointv1.PaymentMethodRecord{
PermissionBound: &pboundv1.PermissionBound{
Storable: &storablev1.Storable{Id: mainMethodRef},
},
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN,
Data: mustMarshalBSON(t, pkgmodel.TokenPaymentData{
Token: "tok-1",
CardholderName: "John",
ExpMonth: "1",
ExpYear: "2031",
Country: "US",
}),
},
},
}, nil
},
})
intent := &transferv1.TransferIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-src",
}),
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PayeeRef{PayeeRef: payeeRef},
},
Amount: newMoney("7.5", "USD"),
}
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: orgRef,
InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent,
})
if err != nil {
t.Fatalf("HydrateOne returned error: %v", err)
}
if getReq == nil {
t.Fatalf("expected GetPaymentMethodPrivate call")
}
if getReq.GetOrganizationRef() != orgRef || getReq.GetPayeeRef() != payeeRef {
t.Fatalf("unexpected request: %#v", getReq)
}
if getReq.GetEndpoint() != methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_DESTINATION {
t.Fatalf("expected destination endpoint role, got %s", getReq.GetEndpoint().String())
}
if got.Destination.Type != QuoteEndpointTypeCard {
t.Fatalf("expected card destination, got %s", got.Destination.Type)
}
if got.Destination.Card == nil || got.Destination.Card.Token != "tok-1" {
t.Fatalf("unexpected selected destination method: %#v", got.Destination)
}
if got.Destination.PaymentMethodRef != mainMethodRef {
t.Fatalf("expected selected main method ref %q, got %q", mainMethodRef, got.Destination.PaymentMethodRef)
}
}
func TestHydrateOne_ResolvesInlineAccountPaymentMethodViaPrivateMethod(t *testing.T) {
orgRef := bson.NewObjectID().Hex()
accountRecipientRef := bson.NewObjectID().Hex()
childMethodRef := bson.NewObjectID().Hex()
var getReq *methodsv1.GetPaymentMethodPrivateRequest
h := New(&fakeMethodsClient{
getPaymentMethodPrivateFn: func(
_ context.Context,
req *methodsv1.GetPaymentMethodPrivateRequest,
_ ...grpc.CallOption,
) (*methodsv1.GetPaymentMethodPrivateResponse, error) {
getReq = req
return &methodsv1.GetPaymentMethodPrivateResponse{
PaymentMethodRecord: &endpointv1.PaymentMethodRecord{
PermissionBound: &pboundv1.PermissionBound{
Storable: &storablev1.Storable{Id: childMethodRef},
},
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-child-main",
}),
},
},
}, nil
},
})
intent := &transferv1.TransferIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-src",
}),
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_ACCOUNT,
RecipientRef: accountRecipientRef,
},
},
},
Amount: newMoney("4.2", "USD"),
}
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: orgRef,
InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent,
})
if err != nil {
t.Fatalf("HydrateOne returned error: %v", err)
}
if getReq == nil {
t.Fatalf("expected GetPaymentMethodPrivate call")
}
if getReq.GetOrganizationRef() != orgRef || getReq.GetPayeeRef() != accountRecipientRef {
t.Fatalf("unexpected request: %#v", getReq)
}
if getReq.GetEndpoint() != methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_DESTINATION {
t.Fatalf("expected destination endpoint role, got %s", getReq.GetEndpoint().String())
}
if got.Destination.Type != QuoteEndpointTypeManagedWallet {
t.Fatalf("expected managed wallet destination, got %s", got.Destination.Type)
}
if got.Destination.ManagedWallet == nil || got.Destination.ManagedWallet.ManagedWalletRef != "mw-child-main" {
t.Fatalf("unexpected resolved destination: %#v", got.Destination.ManagedWallet)
}
if got.Destination.PaymentMethodRef != childMethodRef {
t.Fatalf("expected resolved child method ref %q, got %q", childMethodRef, got.Destination.PaymentMethodRef)
}
}
func TestHydrateOne_ReferenceWithoutMethodsClientFails(t *testing.T) {
h := New(nil)
intent := &transferv1.TransferIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: bson.NewObjectID().Hex()},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-dst",
}),
},
},
},
Amount: newMoney("1", "USD"),
}
_, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: bson.NewObjectID().Hex(),
InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent,
})
if !errors.Is(err, ErrPaymentMethodsClientRequired) {
t.Fatalf("expected ErrPaymentMethodsClientRequired, got %v", err)
}
}
func TestHydrateMany_IndexesError(t *testing.T) {
h := New(nil)
intents := []*transferv1.TransferIntent{
{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-src",
}),
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{
Pan: "4111111111111111",
ExpMonth: "12",
ExpYear: "2030",
}),
},
},
},
Amount: newMoney("1", "USD"),
},
{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{
WalletID: "mw-src-2",
}),
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{
Pan: "4111111111111111",
ExpMonth: "nope",
ExpYear: "2030",
}),
},
},
},
Amount: newMoney("2", "USD"),
},
}
_, err := h.HydrateMany(context.Background(), HydrateManyInput{
OrganizationRef: bson.NewObjectID().Hex(),
InitiatorRef: bson.NewObjectID().Hex(),
Intents: intents,
})
if err == nil {
t.Fatalf("expected batch hydration error")
}
if !strings.Contains(err.Error(), "intents[1]") {
t.Fatalf("expected indexed error, got %v", err)
}
}
type fakeMethodsClient struct {
getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error)
}
func (f *fakeMethodsClient) GetPaymentMethodPrivate(
ctx context.Context,
req *methodsv1.GetPaymentMethodPrivateRequest,
opts ...grpc.CallOption,
) (*methodsv1.GetPaymentMethodPrivateResponse, error) {
if f.getPaymentMethodPrivateFn == nil {
return nil, errors.New("unexpected GetPaymentMethodPrivate call")
}
return f.getPaymentMethodPrivateFn(ctx, req, opts...)
}
func newMoney(amount, currency string) *moneyv1.Money {
return &moneyv1.Money{
Amount: amount,
Currency: currency,
}
}
func mustMarshalBSON(t *testing.T, value any) []byte {
t.Helper()
data, err := bson.Marshal(value)
if err != nil {
t.Fatalf("marshal bson: %v", err)
}
return data
}

View File

@@ -0,0 +1,20 @@
package shared
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
)
func NormalizeFundingMode(mode model.FundingMode) model.FundingMode {
switch strings.ToLower(strings.TrimSpace(string(mode))) {
case string(model.FundingModeNone):
return model.FundingModeNone
case string(model.FundingModeBalanceReserve):
return model.FundingModeBalanceReserve
case string(model.FundingModeDepositObserved):
return model.FundingModeDepositObserved
default:
return model.FundingModeUnspecified
}
}