outbox for gateways
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "ationv2.PaymentQuote{QuoteRef: in.Item.Intent.Ref},
|
||||
}, nil
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "e_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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
@@ -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 := "ationv2.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
|
||||
}
|
||||
@@ -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 := "ationv2.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "ationv2.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: "ationv2.QuotePaymentsResponse{
|
||||
QuoteRef: strings.TrimSpace(record.QuoteRef),
|
||||
Quotes: quotes,
|
||||
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
|
||||
},
|
||||
Record: record,
|
||||
}, nil
|
||||
}
|
||||
@@ -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 := "ationv2.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, "ationv2.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 "ationv2.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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 := "ationv2.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 := "ationv2.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 := "ationv2.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 := "e_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 := "ationv2.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, "ationv2.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 := "ationv2.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "ationv2.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
|
||||
}
|
||||
@@ -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 := "ationv2.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 := "ationv2.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 "ationv2.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(),
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 := "ationv2.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 := "ationv2.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 := "ationv2.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, "|")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "ationv2.PaymentQuote_Executable{Executable: true}
|
||||
return
|
||||
}
|
||||
quote.ExecutionStatus = "ationv2.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
|
||||
}
|
||||
}
|
||||
@@ -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 := "ationv2.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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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 := "ationv2.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 := "ationv2.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 := "ationv2.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 := "ationv2.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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 := "ationv2.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: "ationv2.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: "ationv2.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: "ationv2.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: "ationv2.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: "ationv2.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 := "ationv2.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: "ationv2.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: "ationv2.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: "ationv2.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: "ationv2.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: "ationv2.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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 := "ationv2.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, "ationv2.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 := "ationv2.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 := "ationv2.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 = "ationv2.PaymentQuote_Executable{Executable: true}
|
||||
} else {
|
||||
result.ExecutionStatus = "ationv2.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)
|
||||
}
|
||||
@@ -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: "ationv2.RouteSpecification{
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "monetix",
|
||||
PayoutMethod: "CARD",
|
||||
SettlementAsset: "USD",
|
||||
SettlementModel: "FIX_SOURCE",
|
||||
},
|
||||
Conditions: "ationv2.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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,7 @@
|
||||
package transfer_intent_hydrator
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPaymentMethodsClientRequired = errors.New("payment methods client is required to resolve endpoint references")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
20
api/payments/quotation/internal/shared/funding.go
Normal file
20
api/payments/quotation/internal/shared/funding.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user