wallets listing dedupe
This commit is contained in:
@@ -19,11 +19,13 @@ func (s *svc) Fingerprint(in FPInput) (string, error) {
|
||||
if quotationRef == "" {
|
||||
return "", merrors.InvalidArgument("quotation_ref is required")
|
||||
}
|
||||
intentRef := strings.TrimSpace(in.IntentRef)
|
||||
clientPaymentRef := strings.TrimSpace(in.ClientPaymentRef)
|
||||
|
||||
payload := strings.Join([]string{
|
||||
"org=" + orgRef,
|
||||
"quote=" + quotationRef,
|
||||
"intent=" + intentRef,
|
||||
"client=" + clientPaymentRef,
|
||||
}, hashSep)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type Service interface {
|
||||
type FPInput struct {
|
||||
OrganizationRef string
|
||||
QuotationRef string
|
||||
IntentRef string
|
||||
ClientPaymentRef string
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestFingerprint_StableAndTrimmed(t *testing.T) {
|
||||
a, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
|
||||
QuotationRef: " quote-1 ",
|
||||
IntentRef: " intent-1 ",
|
||||
ClientPaymentRef: " client-1 ",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -24,6 +25,7 @@ func TestFingerprint_StableAndTrimmed(t *testing.T) {
|
||||
b, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -40,6 +42,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||
base, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -49,6 +52,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||
diffQuote, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-2",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -61,6 +65,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||
diffClient, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-2",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -69,6 +74,19 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||
if base == diffClient {
|
||||
t.Fatalf("expected different fingerprint for different client_payment_ref")
|
||||
}
|
||||
|
||||
diffIntent, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-2",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffIntent {
|
||||
t.Fatalf("expected different fingerprint for different intent_ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_RequiresBusinessFields(t *testing.T) {
|
||||
|
||||
@@ -7,4 +7,6 @@ var (
|
||||
ErrQuoteExpired = errors.New("quotation_ref expired")
|
||||
ErrQuoteNotExecutable = errors.New("quotation_ref is not executable")
|
||||
ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch")
|
||||
ErrIntentRefRequired = errors.New("intent_ref is required for batch quotation")
|
||||
ErrIntentRefNotFound = errors.New("intent_ref not found in quotation")
|
||||
)
|
||||
|
||||
@@ -22,11 +22,13 @@ type Resolver interface {
|
||||
type Input struct {
|
||||
OrganizationID bson.ObjectID
|
||||
QuotationRef string
|
||||
IntentRef string
|
||||
}
|
||||
|
||||
// Output contains extracted canonical snapshots for execution.
|
||||
type Output struct {
|
||||
QuotationRef string
|
||||
IntentRef string
|
||||
IntentSnapshot model.PaymentIntent
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ type svc struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type resolvedQuoteItem struct {
|
||||
Intent model.PaymentIntent
|
||||
Quote *model.PaymentQuoteSnapshot
|
||||
Status *model.QuoteStatusV2
|
||||
}
|
||||
|
||||
func (s *svc) Resolve(
|
||||
ctx context.Context,
|
||||
store Store,
|
||||
@@ -33,6 +39,7 @@ func (s *svc) Resolve(
|
||||
if quoteRef == "" {
|
||||
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||
}
|
||||
intentRef := strings.TrimSpace(in.IntentRef)
|
||||
|
||||
record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef)
|
||||
if err != nil {
|
||||
@@ -45,16 +52,12 @@ func (s *svc) Resolve(
|
||||
return nil, ErrQuoteNotFound
|
||||
}
|
||||
|
||||
if err := ensureExecutable(record, s.now().UTC()); err != nil {
|
||||
item, err := resolveRecordItem(record, intentRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intentSnapshot, err := extractIntentSnapshot(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quoteSnapshot, err := extractQuoteSnapshot(record)
|
||||
if err != nil {
|
||||
if err := ensureExecutable(record, item.Status, s.now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -62,18 +65,23 @@ func (s *svc) Resolve(
|
||||
if outputRef == "" {
|
||||
outputRef = quoteRef
|
||||
}
|
||||
if quoteSnapshot != nil && strings.TrimSpace(quoteSnapshot.QuoteRef) == "" {
|
||||
quoteSnapshot.QuoteRef = outputRef
|
||||
if item.Quote != nil && strings.TrimSpace(item.Quote.QuoteRef) == "" {
|
||||
item.Quote.QuoteRef = outputRef
|
||||
}
|
||||
|
||||
return &Output{
|
||||
QuotationRef: outputRef,
|
||||
IntentSnapshot: intentSnapshot,
|
||||
QuoteSnapshot: quoteSnapshot,
|
||||
IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef),
|
||||
IntentSnapshot: item.Intent,
|
||||
QuoteSnapshot: item.Quote,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error {
|
||||
func ensureExecutable(
|
||||
record *model.PaymentQuoteRecord,
|
||||
status *model.QuoteStatusV2,
|
||||
now time.Time,
|
||||
) error {
|
||||
if record == nil {
|
||||
return ErrQuoteNotFound
|
||||
}
|
||||
@@ -85,10 +93,6 @@ func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error {
|
||||
return fmt.Errorf("%w: %s", ErrQuoteNotExecutable, note)
|
||||
}
|
||||
|
||||
status, err := extractSingleStatus(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == nil {
|
||||
// Legacy records may not have status metadata.
|
||||
return nil
|
||||
@@ -116,58 +120,131 @@ func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error {
|
||||
}
|
||||
}
|
||||
|
||||
func extractSingleStatus(record *model.PaymentQuoteRecord) (*model.QuoteStatusV2, error) {
|
||||
func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
|
||||
if record == nil {
|
||||
return nil, ErrQuoteShapeMismatch
|
||||
return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if len(record.StatusesV2) > 0 {
|
||||
if len(record.StatusesV2) != 1 {
|
||||
return nil, fmt.Errorf("%w: expected single status", ErrQuoteShapeMismatch)
|
||||
|
||||
hasArrayShape := len(record.Intents) > 0 || len(record.Quotes) > 0 || len(record.StatusesV2) > 0
|
||||
if hasArrayShape {
|
||||
return resolveArrayShapeItem(record, intentRef)
|
||||
}
|
||||
return resolveSingleShapeItem(record, intentRef)
|
||||
}
|
||||
|
||||
func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch)
|
||||
}
|
||||
|
||||
if record.Quote == nil {
|
||||
return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if isEmptyIntentSnapshot(record.Intent) {
|
||||
return nil, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if intentRef != "" {
|
||||
recordIntentRef := strings.TrimSpace(record.Intent.Ref)
|
||||
if recordIntentRef == "" || recordIntentRef != intentRef {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
|
||||
}
|
||||
if record.StatusesV2[0] == nil {
|
||||
}
|
||||
|
||||
intentSnapshot, err := cloneIntentSnapshot(record.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quoteSnapshot, err := cloneQuoteSnapshot(record.Quote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resolvedQuoteItem{
|
||||
Intent: intentSnapshot,
|
||||
Quote: quoteSnapshot,
|
||||
Status: record.StatusV2,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
|
||||
if len(record.Intents) == 0 {
|
||||
return nil, fmt.Errorf("%w: intents are empty", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if len(record.Quotes) == 0 {
|
||||
return nil, fmt.Errorf("%w: quotes are empty", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if len(record.Intents) != len(record.Quotes) {
|
||||
return nil, fmt.Errorf("%w: intents and quotes count mismatch", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) {
|
||||
return nil, fmt.Errorf("%w: statuses and quotes count mismatch", ErrQuoteShapeMismatch)
|
||||
}
|
||||
|
||||
index := 0
|
||||
if len(record.Intents) > 1 {
|
||||
if intentRef == "" {
|
||||
return nil, ErrIntentRefRequired
|
||||
}
|
||||
selected, found := findIntentIndex(record.Intents, intentRef)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
|
||||
}
|
||||
index = selected
|
||||
} else if intentRef != "" {
|
||||
if strings.TrimSpace(record.Intents[0].Ref) != intentRef {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
|
||||
}
|
||||
}
|
||||
|
||||
quoteSnapshot := record.Quotes[index]
|
||||
if quoteSnapshot == nil {
|
||||
return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch)
|
||||
}
|
||||
|
||||
intentSnapshot, err := cloneIntentSnapshot(record.Intents[index])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clonedQuote, err := cloneQuoteSnapshot(quoteSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var statusSnapshot *model.QuoteStatusV2
|
||||
if len(record.StatusesV2) > 0 {
|
||||
if record.StatusesV2[index] == nil {
|
||||
return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch)
|
||||
}
|
||||
return record.StatusesV2[0], nil
|
||||
statusSnapshot = record.StatusesV2[index]
|
||||
}
|
||||
return record.StatusV2, nil
|
||||
|
||||
return &resolvedQuoteItem{
|
||||
Intent: intentSnapshot,
|
||||
Quote: clonedQuote,
|
||||
Status: statusSnapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractIntentSnapshot(record *model.PaymentQuoteRecord) (model.PaymentIntent, error) {
|
||||
if record == nil {
|
||||
return model.PaymentIntent{}, ErrQuoteShapeMismatch
|
||||
func findIntentIndex(intents []model.PaymentIntent, targetRef string) (int, bool) {
|
||||
target := strings.TrimSpace(targetRef)
|
||||
if target == "" {
|
||||
return -1, false
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(record.Intents) > 1:
|
||||
return model.PaymentIntent{}, fmt.Errorf("%w: expected single intent", ErrQuoteShapeMismatch)
|
||||
case len(record.Intents) == 1:
|
||||
return cloneIntentSnapshot(record.Intents[0])
|
||||
}
|
||||
|
||||
if isEmptyIntentSnapshot(record.Intent) {
|
||||
return model.PaymentIntent{}, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch)
|
||||
}
|
||||
return cloneIntentSnapshot(record.Intent)
|
||||
}
|
||||
|
||||
func extractQuoteSnapshot(record *model.PaymentQuoteRecord) (*model.PaymentQuoteSnapshot, error) {
|
||||
if record == nil {
|
||||
return nil, ErrQuoteShapeMismatch
|
||||
}
|
||||
|
||||
if record.Quote != nil {
|
||||
return cloneQuoteSnapshot(record.Quote)
|
||||
}
|
||||
if len(record.Quotes) > 1 {
|
||||
return nil, fmt.Errorf("%w: expected single quote", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if len(record.Quotes) == 1 {
|
||||
if record.Quotes[0] == nil {
|
||||
return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch)
|
||||
for idx := range intents {
|
||||
if strings.TrimSpace(intents[idx].Ref) == target {
|
||||
return idx, true
|
||||
}
|
||||
return cloneQuoteSnapshot(record.Quotes[0])
|
||||
}
|
||||
return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch)
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,7 @@ func TestResolve_SingleShapeOK(t *testing.T) {
|
||||
}, Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: "stored-quote-ref",
|
||||
IntentRef: "intent-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
@@ -54,6 +56,9 @@ func TestResolve_SingleShapeOK(t *testing.T) {
|
||||
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
|
||||
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.IntentRef, "intent-1"; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if out.QuoteSnapshot == nil {
|
||||
t.Fatal("expected quote snapshot")
|
||||
}
|
||||
@@ -103,6 +108,9 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := out.IntentRef, "intent-1"; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
|
||||
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
@@ -114,6 +122,129 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "batch-quote-ref",
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "intent-a", Kind: model.PaymentKindPayout},
|
||||
{Ref: "intent-b", Kind: model.PaymentKindPayout},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}},
|
||||
{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "15", Currency: "USDT"}},
|
||||
},
|
||||
StatusesV2: []*model.QuoteStatusV2{
|
||||
{State: model.QuoteStateExecutable},
|
||||
{State: model.QuoteStateExecutable},
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
out, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return record, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: "batch-quote-ref",
|
||||
IntentRef: "intent-b",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := out.IntentSnapshot.Ref, "intent-b"; got != want {
|
||||
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.IntentRef, "intent-b"; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if out.QuoteSnapshot == nil || out.QuoteSnapshot.DebitAmount == nil {
|
||||
t.Fatal("expected quote snapshot with debit amount")
|
||||
}
|
||||
if got, want := out.QuoteSnapshot.DebitAmount.Amount, "15"; got != want {
|
||||
t.Fatalf("selected quote mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "intent-1", Kind: model.PaymentKindPayout},
|
||||
{Ref: "intent-2", Kind: model.PaymentKindPayout},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{},
|
||||
{},
|
||||
},
|
||||
StatusesV2: []*model.QuoteStatusV2{
|
||||
{State: model.QuoteStateExecutable},
|
||||
{State: model.QuoteStateExecutable},
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrIntentRefRequired) {
|
||||
t.Fatalf("expected ErrIntentRefRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "intent-1", Kind: model.PaymentKindPayout},
|
||||
{Ref: "intent-2", Kind: model.PaymentKindPayout},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{},
|
||||
{},
|
||||
},
|
||||
StatusesV2: []*model.QuoteStatusV2{
|
||||
{State: model.QuoteStateExecutable},
|
||||
{State: model.QuoteStateExecutable},
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
IntentRef: "intent-3",
|
||||
})
|
||||
if !errors.Is(err, ErrIntentRefNotFound) {
|
||||
t.Fatalf("expected ErrIntentRefNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotFound(t *testing.T) {
|
||||
resolver := New()
|
||||
|
||||
@@ -232,7 +363,6 @@ func TestResolve_ShapeMismatch(t *testing.T) {
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{},
|
||||
{},
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
@@ -240,6 +370,7 @@ func TestResolve_ShapeMismatch(t *testing.T) {
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
IntentRef: "intent-1",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteShapeMismatch) {
|
||||
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ type Validator interface {
|
||||
type Req struct {
|
||||
Meta *Meta
|
||||
QuotationRef string
|
||||
IntentRef string
|
||||
ClientPaymentRef string
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ type Ctx struct {
|
||||
OrganizationID bson.ObjectID
|
||||
IdempotencyKey string
|
||||
QuotationRef string
|
||||
IntentRef string
|
||||
ClientPaymentRef string
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
const (
|
||||
maxIdempotencyKeyLen = 256
|
||||
maxQuotationRefLen = 128
|
||||
maxIntentRefLen = 128
|
||||
maxClientRefLen = 128
|
||||
)
|
||||
|
||||
@@ -49,6 +50,11 @@ func (s *svc) Validate(req *Req) (*Ctx, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intentRef, err := validateRefToken("intent_ref", req.IntentRef, maxIntentRefLen, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -59,6 +65,7 @@ func (s *svc) Validate(req *Req) (*Ctx, error) {
|
||||
OrganizationID: orgID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
QuotationRef: quotationRef,
|
||||
IntentRef: intentRef,
|
||||
ClientPaymentRef: clientPaymentRef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestValidate_OK(t *testing.T) {
|
||||
},
|
||||
},
|
||||
QuotationRef: " quote-ref-1 ",
|
||||
IntentRef: " intent-ref-1 ",
|
||||
ClientPaymentRef: " client.ref-1 ",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -39,6 +40,9 @@ func TestValidate_OK(t *testing.T) {
|
||||
if got, want := ctx.QuotationRef, "quote-ref-1"; got != want {
|
||||
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := ctx.IntentRef, "intent-ref-1"; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := ctx.ClientPaymentRef, "client.ref-1"; got != want {
|
||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
@@ -66,12 +70,16 @@ func TestValidate_ClientPaymentRefOptional(t *testing.T) {
|
||||
if ctx.ClientPaymentRef != "" {
|
||||
t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef)
|
||||
}
|
||||
if ctx.IntentRef != "" {
|
||||
t.Fatalf("expected empty intent_ref, got %q", ctx.IntentRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Errors(t *testing.T) {
|
||||
orgID := bson.NewObjectID().Hex()
|
||||
tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen)
|
||||
tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen)
|
||||
tooLongIntent := "i" + strings.Repeat("a", maxIntentRefLen)
|
||||
tooLongClient := "c" + strings.Repeat("a", maxClientRefLen)
|
||||
|
||||
tests := []struct {
|
||||
@@ -185,6 +193,28 @@ func TestValidate_Errors(t *testing.T) {
|
||||
ClientPaymentRef: tooLongClient,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too long intent ref",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: tooLongIntent,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad intent ref shape",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad client payment ref shape",
|
||||
req: &Req{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package xplan
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotExecutable = errors.New("quote is not executable for runtime compilation")
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
package xplan
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
// Compiler builds execution runtime step graph from resolved quote snapshots.
|
||||
type Compiler interface {
|
||||
Compile(in Input) (*Graph, error)
|
||||
}
|
||||
|
||||
// StepKind classifies graph step intent.
|
||||
type StepKind string
|
||||
|
||||
const (
|
||||
StepKindUnspecified StepKind = "unspecified"
|
||||
StepKindLiquidityCheck StepKind = "liquidity_check"
|
||||
StepKindPrefunding StepKind = "prefunding"
|
||||
StepKindRailSend StepKind = "rail_send"
|
||||
StepKindRailObserve StepKind = "rail_observe"
|
||||
StepKindFundsCredit StepKind = "funds_credit"
|
||||
StepKindFundsDebit StepKind = "funds_debit"
|
||||
StepKindFundsMove StepKind = "funds_move"
|
||||
StepKindFundsBlock StepKind = "funds_block"
|
||||
StepKindFundsRelease StepKind = "funds_release"
|
||||
)
|
||||
|
||||
// Input is the compiler payload.
|
||||
type Input struct {
|
||||
IntentSnapshot model.PaymentIntent
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
Policies []Policy
|
||||
}
|
||||
|
||||
// Graph is a compiled runtime execution graph.
|
||||
type Graph struct {
|
||||
RouteRef string
|
||||
Readiness paymenttypes.QuoteExecutionReadiness
|
||||
Steps []Step
|
||||
}
|
||||
|
||||
// Step is one compiled graph node.
|
||||
type Step struct {
|
||||
StepRef string
|
||||
StepCode string
|
||||
Kind StepKind
|
||||
Action model.RailOperation
|
||||
DependsOn []string
|
||||
Rail model.Rail
|
||||
Gateway string
|
||||
InstanceID string
|
||||
HopIndex uint32
|
||||
HopRole paymenttypes.QuoteRouteHopRole
|
||||
Visibility model.ReportVisibility
|
||||
UserLabel string
|
||||
CommitPolicy model.CommitPolicy
|
||||
CommitAfter []string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Custody classifies whether a rail is internal to orchestrator or external.
|
||||
type Custody string
|
||||
|
||||
const (
|
||||
CustodyUnspecified Custody = "unspecified"
|
||||
CustodyInternal Custody = "internal"
|
||||
CustodyExternal Custody = "external"
|
||||
)
|
||||
|
||||
// EndpointMatch defines optional selectors for one boundary endpoint.
|
||||
type EndpointMatch struct {
|
||||
Rail *model.Rail `json:"rail,omitempty" bson:"rail,omitempty"`
|
||||
Custody *Custody `json:"custody,omitempty" bson:"custody,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty" bson:"gateway,omitempty"`
|
||||
Network string `json:"network,omitempty" bson:"network,omitempty"`
|
||||
Method string `json:"method,omitempty" bson:"method,omitempty"`
|
||||
}
|
||||
|
||||
// EdgeMatch defines source/target selectors for a boundary policy.
|
||||
type EdgeMatch struct {
|
||||
Source EndpointMatch `json:"source" bson:"source"`
|
||||
Target EndpointMatch `json:"target" bson:"target"`
|
||||
}
|
||||
|
||||
// PolicyStep defines one operation step emitted by a matching policy.
|
||||
type PolicyStep struct {
|
||||
Code string `json:"code" bson:"code"`
|
||||
Action model.RailOperation `json:"action" bson:"action"`
|
||||
Rail *model.Rail `json:"rail,omitempty" bson:"rail,omitempty"`
|
||||
DependsOn []string `json:"depends_on,omitempty" bson:"depends_on,omitempty"`
|
||||
Visibility model.ReportVisibility `json:"visibility,omitempty" bson:"visibility,omitempty"`
|
||||
UserLabel string `json:"user_label,omitempty" bson:"user_label,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty" bson:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Policy defines optional edge-specific expansion override.
|
||||
type Policy struct {
|
||||
ID string `json:"id" bson:"id"`
|
||||
Enabled *bool `json:"enabled,omitempty" bson:"enabled,omitempty"`
|
||||
Priority int `json:"priority,omitempty" bson:"priority,omitempty"`
|
||||
Match EdgeMatch `json:"match" bson:"match"`
|
||||
Steps []PolicyStep `json:"steps" bson:"steps"`
|
||||
Success []PolicyStep `json:"success,omitempty" bson:"success,omitempty"`
|
||||
Failure []PolicyStep `json:"failure,omitempty" bson:"failure,omitempty"`
|
||||
}
|
||||
|
||||
func New() Compiler {
|
||||
return &svc{}
|
||||
}
|
||||
@@ -0,0 +1,989 @@
|
||||
package xplan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
type svc struct{}
|
||||
|
||||
type normalizedHop struct {
|
||||
index uint32
|
||||
rail model.Rail
|
||||
gateway string
|
||||
instanceID string
|
||||
network string
|
||||
role paymenttypes.QuoteRouteHopRole
|
||||
pos int
|
||||
}
|
||||
|
||||
type expansion struct {
|
||||
steps []Step
|
||||
lastMainRef string
|
||||
refSeq map[string]int
|
||||
externalObserved map[string]string
|
||||
}
|
||||
|
||||
func newExpansion() *expansion {
|
||||
return &expansion{
|
||||
refSeq: map[string]int{},
|
||||
externalObserved: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) Compile(in Input) (*Graph, error) {
|
||||
if isEmptyIntentSnapshot(in.IntentSnapshot) {
|
||||
return nil, merrors.InvalidArgument("intent_snapshot is required")
|
||||
}
|
||||
if in.QuoteSnapshot == nil {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot is required")
|
||||
}
|
||||
if in.QuoteSnapshot.Route == nil {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.route is required")
|
||||
}
|
||||
|
||||
conditions := in.QuoteSnapshot.ExecutionConditions
|
||||
readiness := paymenttypes.QuoteExecutionReadinessUnspecified
|
||||
if conditions != nil {
|
||||
readiness = conditions.Readiness
|
||||
}
|
||||
if readiness == paymenttypes.QuoteExecutionReadinessIndicative {
|
||||
return nil, ErrNotExecutable
|
||||
}
|
||||
|
||||
hops, err := normalizeRouteHops(in.QuoteSnapshot.Route, in.IntentSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ex := newExpansion()
|
||||
appendGuards(ex, conditions)
|
||||
|
||||
if len(hops) == 1 {
|
||||
if err := s.expandSingleHop(ex, hops[0], in.IntentSnapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < len(hops)-1; i++ {
|
||||
from := hops[i]
|
||||
to := hops[i+1]
|
||||
|
||||
policy := selectPolicy(from, to, in.Policies)
|
||||
if policy != nil {
|
||||
if err := s.applyPolicy(ex, *policy, from, to, in.IntentSnapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.applyDefaultBoundary(ex, from, to, in.IntentSnapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ex.steps) == 0 {
|
||||
return nil, merrors.InvalidArgument("compiled graph is empty")
|
||||
}
|
||||
|
||||
return &Graph{
|
||||
RouteRef: strings.TrimSpace(in.QuoteSnapshot.Route.RouteRef),
|
||||
Readiness: readiness,
|
||||
Steps: ex.steps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.PaymentIntent) error {
|
||||
if isExternalRail(hop.rail) {
|
||||
_, err := s.ensureExternalObserved(ex, hop, intent)
|
||||
return err
|
||||
}
|
||||
|
||||
switch hop.role {
|
||||
case paymenttypes.QuoteRouteHopRoleSource:
|
||||
ex.appendMain(Step{
|
||||
StepCode: singleHopCode(hop, "debit"),
|
||||
Kind: StepKindFundsDebit,
|
||||
Action: model.RailOperationDebit,
|
||||
Rail: hop.rail,
|
||||
HopIndex: hop.index,
|
||||
HopRole: hop.role,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
})
|
||||
case paymenttypes.QuoteRouteHopRoleDestination:
|
||||
ex.appendMain(Step{
|
||||
StepCode: singleHopCode(hop, "credit"),
|
||||
Kind: StepKindFundsCredit,
|
||||
Action: model.RailOperationCredit,
|
||||
Rail: hop.rail,
|
||||
HopIndex: hop.index,
|
||||
HopRole: hop.role,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
})
|
||||
default:
|
||||
ex.appendMain(Step{
|
||||
StepCode: singleHopCode(hop, "move"),
|
||||
Kind: StepKindFundsMove,
|
||||
Action: model.RailOperationMove,
|
||||
Rail: hop.rail,
|
||||
HopIndex: hop.index,
|
||||
HopRole: hop.role,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) applyDefaultBoundary(
|
||||
ex *expansion,
|
||||
from normalizedHop,
|
||||
to normalizedHop,
|
||||
intent model.PaymentIntent,
|
||||
) error {
|
||||
switch {
|
||||
case isExternalRail(from.rail) && isInternalRail(to.rail):
|
||||
if _, err := s.ensureExternalObserved(ex, from, intent); err != nil {
|
||||
return err
|
||||
}
|
||||
ex.appendMain(makeFundsCreditStep(from, to, internalRailForBoundary(from, to)))
|
||||
return nil
|
||||
|
||||
case isInternalRail(from.rail) && isExternalRail(to.rail):
|
||||
internalRail := internalRailForBoundary(from, to)
|
||||
ex.appendMain(makeFundsBlockStep(from, to, internalRail))
|
||||
observeRef, err := s.ensureExternalObserved(ex, to, intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appendSettlementBranches(ex, from, to, internalRail, observeRef)
|
||||
return nil
|
||||
|
||||
case isExternalRail(from.rail) && isExternalRail(to.rail):
|
||||
if _, err := s.ensureExternalObserved(ex, from, intent); err != nil {
|
||||
return err
|
||||
}
|
||||
internalRail := internalRailForBoundary(from, to)
|
||||
ex.appendMain(makeFundsCreditStep(from, to, internalRail))
|
||||
ex.appendMain(makeFundsBlockStep(from, to, internalRail))
|
||||
observeRef, err := s.ensureExternalObserved(ex, to, intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appendSettlementBranches(ex, from, to, internalRail, observeRef)
|
||||
return nil
|
||||
|
||||
case isInternalRail(from.rail) && isInternalRail(to.rail):
|
||||
ex.appendMain(makeFundsMoveStep(from, to, internalRailForBoundary(from, to)))
|
||||
return nil
|
||||
|
||||
default:
|
||||
return merrors.InvalidArgument("unsupported rail boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) {
|
||||
key := observedKey(hop)
|
||||
if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
sendStep := makeRailSendStep(hop, intent)
|
||||
sendRef := ex.appendMain(sendStep)
|
||||
|
||||
observeStep := makeRailObserveStep(hop, intent)
|
||||
if sendRef != "" {
|
||||
observeStep.DependsOn = []string{sendRef}
|
||||
}
|
||||
observeRef := ex.appendMain(observeStep)
|
||||
|
||||
ex.externalObserved[key] = observeRef
|
||||
return observeRef, nil
|
||||
}
|
||||
|
||||
func (s *svc) applyPolicy(
|
||||
ex *expansion,
|
||||
policy Policy,
|
||||
from normalizedHop,
|
||||
to normalizedHop,
|
||||
intent model.PaymentIntent,
|
||||
) error {
|
||||
if len(policy.Steps) == 0 {
|
||||
return merrors.InvalidArgument("policy.steps are required")
|
||||
}
|
||||
|
||||
anchorRef := ""
|
||||
for i := range policy.Steps {
|
||||
step, err := policyStepToStep(policy.Steps[i], from, to, intent)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " step[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
anchorRef = ex.appendMain(step)
|
||||
}
|
||||
if strings.TrimSpace(anchorRef) == "" {
|
||||
return merrors.InvalidArgument("policy produced no anchor step")
|
||||
}
|
||||
|
||||
for i := range policy.Success {
|
||||
step, err := policyStepToStep(policy.Success[i], from, to, intent)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " success[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
if len(step.DependsOn) == 0 {
|
||||
step.DependsOn = []string{anchorRef}
|
||||
}
|
||||
if len(step.CommitAfter) == 0 {
|
||||
step.CommitAfter = cloneStringSlice(step.DependsOn)
|
||||
}
|
||||
step.CommitPolicy = model.CommitPolicyAfterSuccess
|
||||
ex.appendBranch(step)
|
||||
}
|
||||
|
||||
for i := range policy.Failure {
|
||||
step, err := policyStepToStep(policy.Failure[i], from, to, intent)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " failure[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
if len(step.DependsOn) == 0 {
|
||||
step.DependsOn = []string{anchorRef}
|
||||
}
|
||||
if len(step.CommitAfter) == 0 {
|
||||
step.CommitAfter = cloneStringSlice(step.DependsOn)
|
||||
}
|
||||
step.CommitPolicy = model.CommitPolicyAfterFailure
|
||||
ex.appendBranch(step)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectPolicy(from normalizedHop, to normalizedHop, policies []Policy) *Policy {
|
||||
best := -1
|
||||
bestPriority := 0
|
||||
|
||||
for i := range policies {
|
||||
policy := &policies[i]
|
||||
if !policyEnabled(*policy) {
|
||||
continue
|
||||
}
|
||||
if !policyMatches(policy.Match, from, to) {
|
||||
continue
|
||||
}
|
||||
|
||||
if best == -1 || policy.Priority > bestPriority {
|
||||
best = i
|
||||
bestPriority = policy.Priority
|
||||
}
|
||||
}
|
||||
|
||||
if best == -1 {
|
||||
return nil
|
||||
}
|
||||
return &policies[best]
|
||||
}
|
||||
|
||||
func policyEnabled(policy Policy) bool {
|
||||
if policy.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *policy.Enabled
|
||||
}
|
||||
|
||||
func policyMatches(match EdgeMatch, from normalizedHop, to normalizedHop) bool {
|
||||
return endpointMatches(match.Source, from) && endpointMatches(match.Target, to)
|
||||
}
|
||||
|
||||
func endpointMatches(match EndpointMatch, hop normalizedHop) bool {
|
||||
if match.Rail != nil && normalizeRail(string(*match.Rail)) != hop.rail {
|
||||
return false
|
||||
}
|
||||
if match.Custody != nil && *match.Custody != custodyForRail(hop.rail) {
|
||||
return false
|
||||
}
|
||||
if gateway := strings.TrimSpace(match.Gateway); gateway != "" && !strings.EqualFold(gateway, hop.gateway) {
|
||||
return false
|
||||
}
|
||||
if network := strings.TrimSpace(match.Network); network != "" && !strings.EqualFold(network, hop.network) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(match.Method) != "" {
|
||||
// Method-matching is reserved for the next phase once method is passed in intent/route context.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func policyStepToStep(spec PolicyStep, from normalizedHop, to normalizedHop, intent model.PaymentIntent) (Step, error) {
|
||||
code := strings.TrimSpace(spec.Code)
|
||||
if code == "" {
|
||||
return Step{}, merrors.InvalidArgument("code is required")
|
||||
}
|
||||
|
||||
action := normalizeAction(spec.Action)
|
||||
if action == model.RailOperationUnspecified {
|
||||
return Step{}, merrors.InvalidArgument("action is required")
|
||||
}
|
||||
|
||||
rail := inferPolicyRail(spec, action, from, to)
|
||||
if rail == model.RailUnspecified {
|
||||
return Step{}, merrors.InvalidArgument("rail could not be inferred")
|
||||
}
|
||||
|
||||
hopIndex, hopRole, gateway, instanceID := resolveStepContext(rail, action, from, to)
|
||||
|
||||
visibility := model.NormalizeReportVisibility(spec.Visibility)
|
||||
if visibility == model.ReportVisibilityUnspecified {
|
||||
visibility = defaultVisibilityForAction(action, hopRole)
|
||||
}
|
||||
|
||||
userLabel := strings.TrimSpace(spec.UserLabel)
|
||||
if userLabel == "" && visibility == model.ReportVisibilityUser {
|
||||
userLabel = defaultUserLabel(action, rail, hopRole, intent.Kind)
|
||||
}
|
||||
|
||||
return Step{
|
||||
StepCode: code,
|
||||
Kind: kindForAction(action),
|
||||
Action: action,
|
||||
DependsOn: cloneStringSlice(spec.DependsOn),
|
||||
Rail: rail,
|
||||
Gateway: gateway,
|
||||
InstanceID: instanceID,
|
||||
HopIndex: hopIndex,
|
||||
HopRole: hopRole,
|
||||
Visibility: visibility,
|
||||
UserLabel: userLabel,
|
||||
Metadata: cloneMetadata(spec.Metadata),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAction(action model.RailOperation) model.RailOperation {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(action))) {
|
||||
case string(model.RailOperationDebit):
|
||||
return model.RailOperationDebit
|
||||
case string(model.RailOperationCredit):
|
||||
return model.RailOperationCredit
|
||||
case string(model.RailOperationExternalDebit):
|
||||
return model.RailOperationExternalDebit
|
||||
case string(model.RailOperationExternalCredit):
|
||||
return model.RailOperationExternalCredit
|
||||
case string(model.RailOperationMove):
|
||||
return model.RailOperationMove
|
||||
case string(model.RailOperationSend):
|
||||
return model.RailOperationSend
|
||||
case string(model.RailOperationFee):
|
||||
return model.RailOperationFee
|
||||
case string(model.RailOperationObserveConfirm):
|
||||
return model.RailOperationObserveConfirm
|
||||
case string(model.RailOperationFXConvert):
|
||||
return model.RailOperationFXConvert
|
||||
case string(model.RailOperationBlock):
|
||||
return model.RailOperationBlock
|
||||
case string(model.RailOperationRelease):
|
||||
return model.RailOperationRelease
|
||||
default:
|
||||
return model.RailOperationUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalizedHop, to normalizedHop) model.Rail {
|
||||
if spec.Rail != nil {
|
||||
return normalizeRail(string(*spec.Rail))
|
||||
}
|
||||
|
||||
switch action {
|
||||
case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee:
|
||||
return to.rail
|
||||
case model.RailOperationBlock,
|
||||
model.RailOperationRelease,
|
||||
model.RailOperationDebit,
|
||||
model.RailOperationCredit,
|
||||
model.RailOperationExternalDebit,
|
||||
model.RailOperationExternalCredit,
|
||||
model.RailOperationMove:
|
||||
return internalRailForBoundary(from, to)
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func resolveStepContext(
|
||||
rail model.Rail,
|
||||
action model.RailOperation,
|
||||
from normalizedHop,
|
||||
to normalizedHop,
|
||||
) (uint32, paymenttypes.QuoteRouteHopRole, string, string) {
|
||||
if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) {
|
||||
return to.index, to.role, to.gateway, to.instanceID
|
||||
}
|
||||
if rail == from.rail {
|
||||
return from.index, from.role, from.gateway, from.instanceID
|
||||
}
|
||||
if rail == to.rail {
|
||||
return to.index, to.role, to.gateway, to.instanceID
|
||||
}
|
||||
return to.index, paymenttypes.QuoteRouteHopRoleTransit, "", ""
|
||||
}
|
||||
|
||||
func kindForAction(action model.RailOperation) StepKind {
|
||||
switch action {
|
||||
case model.RailOperationSend:
|
||||
return StepKindRailSend
|
||||
case model.RailOperationObserveConfirm:
|
||||
return StepKindRailObserve
|
||||
case model.RailOperationCredit, model.RailOperationExternalCredit:
|
||||
return StepKindFundsCredit
|
||||
case model.RailOperationDebit, model.RailOperationExternalDebit:
|
||||
return StepKindFundsDebit
|
||||
case model.RailOperationMove:
|
||||
return StepKindFundsMove
|
||||
case model.RailOperationBlock:
|
||||
return StepKindFundsBlock
|
||||
case model.RailOperationRelease:
|
||||
return StepKindFundsRelease
|
||||
default:
|
||||
return StepKindUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditions) {
|
||||
if conditions == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if conditions.LiquidityCheckRequiredAtExecution {
|
||||
ex.appendMain(Step{
|
||||
StepCode: "liquidity.check",
|
||||
Kind: StepKindLiquidityCheck,
|
||||
Action: model.RailOperationUnspecified,
|
||||
Rail: model.RailUnspecified,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
})
|
||||
}
|
||||
|
||||
if conditions.PrefundingRequired {
|
||||
ex.appendMain(Step{
|
||||
StepCode: "prefunding.ensure",
|
||||
Kind: StepKindPrefunding,
|
||||
Action: model.RailOperationUnspecified,
|
||||
Rail: model.RailUnspecified,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step {
|
||||
visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role)
|
||||
userLabel := ""
|
||||
if visibility == model.ReportVisibilityUser {
|
||||
userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind)
|
||||
}
|
||||
return Step{
|
||||
StepCode: singleHopCode(hop, "send"),
|
||||
Kind: StepKindRailSend,
|
||||
Action: model.RailOperationSend,
|
||||
Rail: hop.rail,
|
||||
Gateway: hop.gateway,
|
||||
InstanceID: hop.instanceID,
|
||||
HopIndex: hop.index,
|
||||
HopRole: hop.role,
|
||||
Visibility: visibility,
|
||||
UserLabel: userLabel,
|
||||
}
|
||||
}
|
||||
|
||||
func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step {
|
||||
visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role)
|
||||
userLabel := ""
|
||||
if visibility == model.ReportVisibilityUser {
|
||||
userLabel = defaultUserLabel(model.RailOperationObserveConfirm, hop.rail, hop.role, intent.Kind)
|
||||
}
|
||||
return Step{
|
||||
StepCode: singleHopCode(hop, "observe"),
|
||||
Kind: StepKindRailObserve,
|
||||
Action: model.RailOperationObserveConfirm,
|
||||
Rail: hop.rail,
|
||||
Gateway: hop.gateway,
|
||||
InstanceID: hop.instanceID,
|
||||
HopIndex: hop.index,
|
||||
HopRole: hop.role,
|
||||
Visibility: visibility,
|
||||
UserLabel: userLabel,
|
||||
}
|
||||
}
|
||||
|
||||
func makeFundsCreditStep(from normalizedHop, to normalizedHop, rail model.Rail) Step {
|
||||
return Step{
|
||||
StepCode: edgeCode(from, to, rail, "credit"),
|
||||
Kind: StepKindFundsCredit,
|
||||
Action: model.RailOperationCredit,
|
||||
Rail: rail,
|
||||
HopIndex: to.index,
|
||||
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
}
|
||||
}
|
||||
|
||||
func makeFundsBlockStep(from normalizedHop, to normalizedHop, rail model.Rail) Step {
|
||||
return Step{
|
||||
StepCode: edgeCode(from, to, rail, "block"),
|
||||
Kind: StepKindFundsBlock,
|
||||
Action: model.RailOperationBlock,
|
||||
Rail: rail,
|
||||
HopIndex: to.index,
|
||||
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
}
|
||||
}
|
||||
|
||||
func makeFundsMoveStep(from normalizedHop, to normalizedHop, rail model.Rail) Step {
|
||||
return Step{
|
||||
StepCode: edgeCode(from, to, rail, "move"),
|
||||
Kind: StepKindFundsMove,
|
||||
Action: model.RailOperationMove,
|
||||
Rail: rail,
|
||||
HopIndex: to.index,
|
||||
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
}
|
||||
}
|
||||
|
||||
func appendSettlementBranches(
|
||||
ex *expansion,
|
||||
from normalizedHop,
|
||||
to normalizedHop,
|
||||
rail model.Rail,
|
||||
anchorObserveRef string,
|
||||
) {
|
||||
if strings.TrimSpace(anchorObserveRef) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
successStep := Step{
|
||||
StepCode: edgeCode(from, to, rail, "debit"),
|
||||
Kind: StepKindFundsDebit,
|
||||
Action: model.RailOperationDebit,
|
||||
DependsOn: []string{anchorObserveRef},
|
||||
Rail: rail,
|
||||
HopIndex: to.index,
|
||||
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
CommitPolicy: model.CommitPolicyAfterSuccess,
|
||||
CommitAfter: []string{anchorObserveRef},
|
||||
Metadata: map[string]string{"mode": "finalize_debit"},
|
||||
}
|
||||
ex.appendBranch(successStep)
|
||||
|
||||
failureStep := Step{
|
||||
StepCode: edgeCode(from, to, rail, "release"),
|
||||
Kind: StepKindFundsRelease,
|
||||
Action: model.RailOperationRelease,
|
||||
DependsOn: []string{anchorObserveRef},
|
||||
Rail: rail,
|
||||
HopIndex: to.index,
|
||||
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
|
||||
Visibility: model.ReportVisibilityHidden,
|
||||
CommitPolicy: model.CommitPolicyAfterFailure,
|
||||
CommitAfter: []string{anchorObserveRef},
|
||||
Metadata: map[string]string{"mode": "unlock_hold"},
|
||||
}
|
||||
ex.appendBranch(failureStep)
|
||||
}
|
||||
|
||||
func (e *expansion) appendMain(step Step) string {
|
||||
step = normalizeStep(step)
|
||||
if len(step.DependsOn) == 0 && strings.TrimSpace(e.lastMainRef) != "" {
|
||||
step.DependsOn = []string{e.lastMainRef}
|
||||
}
|
||||
if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified {
|
||||
step.CommitAfter = cloneStringSlice(step.DependsOn)
|
||||
}
|
||||
step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode))
|
||||
if strings.TrimSpace(step.StepCode) == "" {
|
||||
step.StepCode = step.StepRef
|
||||
}
|
||||
e.steps = append(e.steps, step)
|
||||
e.lastMainRef = step.StepRef
|
||||
return step.StepRef
|
||||
}
|
||||
|
||||
func (e *expansion) appendBranch(step Step) string {
|
||||
step = normalizeStep(step)
|
||||
if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified {
|
||||
step.CommitAfter = cloneStringSlice(step.DependsOn)
|
||||
}
|
||||
step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode))
|
||||
if strings.TrimSpace(step.StepCode) == "" {
|
||||
step.StepCode = step.StepRef
|
||||
}
|
||||
e.steps = append(e.steps, step)
|
||||
return step.StepRef
|
||||
}
|
||||
|
||||
func (e *expansion) nextRef(base string) string {
|
||||
token := sanitizeToken(base)
|
||||
if token == "" {
|
||||
token = "step"
|
||||
}
|
||||
count := e.refSeq[token]
|
||||
e.refSeq[token] = count + 1
|
||||
if count == 0 {
|
||||
return token
|
||||
}
|
||||
return token + "_" + itoa(count+1)
|
||||
}
|
||||
|
||||
func normalizeStep(step Step) Step {
|
||||
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||
step.Gateway = strings.TrimSpace(step.Gateway)
|
||||
step.InstanceID = strings.TrimSpace(step.InstanceID)
|
||||
step.UserLabel = strings.TrimSpace(step.UserLabel)
|
||||
step.Visibility = model.NormalizeReportVisibility(step.Visibility)
|
||||
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
|
||||
step.DependsOn = normalizeStringList(step.DependsOn)
|
||||
step.CommitAfter = normalizeStringList(step.CommitAfter)
|
||||
step.Metadata = normalizeMetadata(step.Metadata)
|
||||
return step
|
||||
}
|
||||
|
||||
func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(policy))) {
|
||||
case string(model.CommitPolicyImmediate):
|
||||
return model.CommitPolicyImmediate
|
||||
case string(model.CommitPolicyAfterSuccess):
|
||||
return model.CommitPolicyAfterSuccess
|
||||
case string(model.CommitPolicyAfterFailure):
|
||||
return model.CommitPolicyAfterFailure
|
||||
case string(model.CommitPolicyAfterCanceled):
|
||||
return model.CommitPolicyAfterCanceled
|
||||
default:
|
||||
return model.CommitPolicyUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility {
|
||||
switch action {
|
||||
case model.RailOperationSend, model.RailOperationObserveConfirm:
|
||||
if role == paymenttypes.QuoteRouteHopRoleDestination {
|
||||
return model.ReportVisibilityUser
|
||||
}
|
||||
return model.ReportVisibilityBackoffice
|
||||
default:
|
||||
return model.ReportVisibilityHidden
|
||||
}
|
||||
}
|
||||
|
||||
func defaultUserLabel(
|
||||
action model.RailOperation,
|
||||
rail model.Rail,
|
||||
role paymenttypes.QuoteRouteHopRole,
|
||||
kind model.PaymentKind,
|
||||
) string {
|
||||
if role != paymenttypes.QuoteRouteHopRoleDestination {
|
||||
return ""
|
||||
}
|
||||
switch action {
|
||||
case model.RailOperationSend:
|
||||
if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
|
||||
return "Card payout submitted"
|
||||
}
|
||||
return "Transfer submitted"
|
||||
case model.RailOperationObserveConfirm:
|
||||
if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
|
||||
return "Card payout confirmed"
|
||||
}
|
||||
return "Transfer confirmed"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func internalRailForBoundary(from normalizedHop, to normalizedHop) model.Rail {
|
||||
if isInternalRail(from.rail) {
|
||||
return from.rail
|
||||
}
|
||||
if isInternalRail(to.rail) {
|
||||
return to.rail
|
||||
}
|
||||
return model.RailLedger
|
||||
}
|
||||
|
||||
func isInternalRail(rail model.Rail) bool {
|
||||
return rail == model.RailLedger
|
||||
}
|
||||
|
||||
func isExternalRail(rail model.Rail) bool {
|
||||
switch rail {
|
||||
case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func custodyForRail(rail model.Rail) Custody {
|
||||
if isInternalRail(rail) {
|
||||
return CustodyInternal
|
||||
}
|
||||
if isExternalRail(rail) {
|
||||
return CustodyExternal
|
||||
}
|
||||
return CustodyUnspecified
|
||||
}
|
||||
|
||||
func singleHopCode(hop normalizedHop, op string) string {
|
||||
return fmt.Sprintf("hop.%d.%s.%s", hop.index, railToken(hop.rail), strings.TrimSpace(op))
|
||||
}
|
||||
|
||||
func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) string {
|
||||
return fmt.Sprintf(
|
||||
"edge.%d_%d.%s.%s",
|
||||
from.index,
|
||||
to.index,
|
||||
railToken(rail),
|
||||
strings.TrimSpace(op),
|
||||
)
|
||||
}
|
||||
|
||||
func railToken(rail model.Rail) string {
|
||||
return strings.ToLower(strings.TrimSpace(string(rail)))
|
||||
}
|
||||
|
||||
func observedKey(hop normalizedHop) string {
|
||||
return fmt.Sprintf("%d:%d:%s:%s", hop.pos, hop.index, strings.TrimSpace(string(hop.rail)), hop.instanceID)
|
||||
}
|
||||
|
||||
func normalizeRouteHops(route *paymenttypes.QuoteRouteSpecification, intent model.PaymentIntent) ([]normalizedHop, error) {
|
||||
if route == nil {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.route is required")
|
||||
}
|
||||
|
||||
if len(route.Hops) == 0 {
|
||||
rail := normalizeRail(route.Rail)
|
||||
if rail == model.RailUnspecified {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.route.rail is required")
|
||||
}
|
||||
return []normalizedHop{
|
||||
{
|
||||
index: 0,
|
||||
rail: rail,
|
||||
gateway: strings.TrimSpace(route.Provider),
|
||||
instanceID: "",
|
||||
network: strings.TrimSpace(route.Network),
|
||||
role: paymenttypes.QuoteRouteHopRoleDestination,
|
||||
pos: 0,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
hops := make([]normalizedHop, 0, len(route.Hops))
|
||||
for i, hop := range route.Hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rail := normalizeRail(firstNonEmpty(hop.Rail, route.Rail))
|
||||
if rail == model.RailUnspecified {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.route.hops[" + itoa(i) + "].rail is required")
|
||||
}
|
||||
|
||||
hops = append(hops, normalizedHop{
|
||||
index: hop.Index,
|
||||
rail: rail,
|
||||
gateway: strings.TrimSpace(firstNonEmpty(hop.Gateway, route.Provider)),
|
||||
instanceID: strings.TrimSpace(hop.InstanceID),
|
||||
network: strings.TrimSpace(firstNonEmpty(hop.Network, route.Network)),
|
||||
role: normalizeHopRole(hop.Role, i, len(route.Hops), intent),
|
||||
pos: i,
|
||||
})
|
||||
}
|
||||
|
||||
if len(hops) == 0 {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.route.hops are empty")
|
||||
}
|
||||
|
||||
slices.SortFunc(hops, func(a, b normalizedHop) int {
|
||||
switch {
|
||||
case a.index < b.index:
|
||||
return -1
|
||||
case a.index > b.index:
|
||||
return 1
|
||||
case a.pos < b.pos:
|
||||
return -1
|
||||
case a.pos > b.pos:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
return hops, nil
|
||||
}
|
||||
|
||||
func normalizeHopRole(
|
||||
role paymenttypes.QuoteRouteHopRole,
|
||||
position int,
|
||||
total int,
|
||||
_ model.PaymentIntent,
|
||||
) paymenttypes.QuoteRouteHopRole {
|
||||
switch role {
|
||||
case paymenttypes.QuoteRouteHopRoleSource,
|
||||
paymenttypes.QuoteRouteHopRoleTransit,
|
||||
paymenttypes.QuoteRouteHopRoleDestination:
|
||||
return role
|
||||
}
|
||||
|
||||
if total <= 1 {
|
||||
return paymenttypes.QuoteRouteHopRoleDestination
|
||||
}
|
||||
if position == 0 {
|
||||
return paymenttypes.QuoteRouteHopRoleSource
|
||||
}
|
||||
if position == total-1 {
|
||||
return paymenttypes.QuoteRouteHopRoleDestination
|
||||
}
|
||||
return paymenttypes.QuoteRouteHopRoleTransit
|
||||
}
|
||||
|
||||
func normalizeRail(raw string) model.Rail {
|
||||
token := strings.ToUpper(strings.TrimSpace(raw))
|
||||
token = strings.ReplaceAll(token, "-", "_")
|
||||
token = strings.ReplaceAll(token, " ", "_")
|
||||
for strings.Contains(token, "__") {
|
||||
token = strings.ReplaceAll(token, "__", "_")
|
||||
}
|
||||
|
||||
switch token {
|
||||
case "CRYPTO":
|
||||
return model.RailCrypto
|
||||
case "PROVIDER_SETTLEMENT", "PROVIDER":
|
||||
return model.RailProviderSettlement
|
||||
case "LEDGER":
|
||||
return model.RailLedger
|
||||
case "CARD_PAYOUT", "CARD":
|
||||
return model.RailCardPayout
|
||||
case "FIAT_ONRAMP", "FIAT_ON_RAMP":
|
||||
return model.RailFiatOnRamp
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
|
||||
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeToken(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
prevUnderscore := false
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
prevUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !prevUnderscore {
|
||||
b.WriteByte('_')
|
||||
prevUnderscore = true
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
|
||||
func normalizeStringList(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
token := strings.TrimSpace(item)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[token]; exists {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
out = append(out, token)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = strings.TrimSpace(value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringSlice(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(values))
|
||||
copy(out, values)
|
||||
return out
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
if v < 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package xplan
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
RouteRef: "route-1",
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{
|
||||
Index: 10,
|
||||
Rail: "CRYPTO",
|
||||
Gateway: "gw-crypto",
|
||||
InstanceID: "crypto-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleSource,
|
||||
},
|
||||
{
|
||||
Index: 20,
|
||||
Rail: "CARD_PAYOUT",
|
||||
Gateway: "gw-card",
|
||||
InstanceID: "card-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleDestination,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
if graph == nil {
|
||||
t.Fatal("expected graph")
|
||||
}
|
||||
if got, want := graph.RouteRef, "route-1"; got != want {
|
||||
t.Fatalf("route_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if len(graph.Steps) != 8 {
|
||||
t.Fatalf("expected 8 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
|
||||
assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice)
|
||||
assertStep(t, graph.Steps[1], "hop.10.crypto.observe", model.RailOperationObserveConfirm, model.RailCrypto, model.ReportVisibilityBackoffice)
|
||||
assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[3], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[4], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[5], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[6], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[7], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
|
||||
|
||||
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("step[1] deps mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := graph.Steps[2].DependsOn, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("step[2] deps mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := graph.Steps[3].DependsOn, []string{graph.Steps[2].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("step[3] deps mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := graph.Steps[4].DependsOn, []string{graph.Steps[3].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("step[4] deps mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := graph.Steps[5].DependsOn, []string{graph.Steps[4].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("step[5] deps mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
|
||||
if graph.Steps[6].CommitPolicy != model.CommitPolicyAfterSuccess {
|
||||
t.Fatalf("expected debit commit policy AFTER_SUCCESS, got %q", graph.Steps[6].CommitPolicy)
|
||||
}
|
||||
if graph.Steps[7].CommitPolicy != model.CommitPolicyAfterFailure {
|
||||
t.Fatalf("expected release commit policy AFTER_FAILURE, got %q", graph.Steps[7].CommitPolicy)
|
||||
}
|
||||
if got, want := graph.Steps[6].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("debit commit_after mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := graph.Steps[7].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("release commit_after mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := graph.Steps[6].Metadata["mode"], "finalize_debit"; got != want {
|
||||
t.Fatalf("expected debit mode %q, got %q", want, got)
|
||||
}
|
||||
if got, want := graph.Steps[7].Metadata["mode"], "unlock_hold"; got != want {
|
||||
t.Fatalf("expected release mode %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
if len(graph.Steps) != 5 {
|
||||
t.Fatalf("expected 5 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
assertStep(t, graph.Steps[0], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[1], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[2], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[3], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[4], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
|
||||
}
|
||||
|
||||
func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindInternalTransfer),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
if len(graph.Steps) != 3 {
|
||||
t.Fatalf("expected 3 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice)
|
||||
assertStep(t, graph.Steps[1], "hop.10.crypto.observe", model.RailOperationObserveConfirm, model.RailCrypto, model.ReportVisibilityBackoffice)
|
||||
assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
}
|
||||
|
||||
func TestCompile_InternalToInternal_UsesMove(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindInternalTransfer),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
if len(graph.Steps) != 1 {
|
||||
t.Fatalf("expected 1 step, got %d", len(graph.Steps))
|
||||
}
|
||||
assertStep(t, graph.Steps[0], "edge.10_20.ledger.move", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden)
|
||||
}
|
||||
|
||||
func TestCompile_GuardsArePrepended(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||
LiquidityCheckRequiredAtExecution: true,
|
||||
PrefundingRequired: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
if len(graph.Steps) != 7 {
|
||||
t.Fatalf("expected 7 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
if graph.Steps[0].Kind != StepKindLiquidityCheck {
|
||||
t.Fatalf("expected first guard liquidity_check, got %q", graph.Steps[0].Kind)
|
||||
}
|
||||
if graph.Steps[1].Kind != StepKindPrefunding {
|
||||
t.Fatalf("expected second guard prefunding, got %q", graph.Steps[1].Kind)
|
||||
}
|
||||
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("prefunding dependency mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := graph.Steps[2].DependsOn, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("first execution step dependency mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_SingleExternalFallback(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
RouteRef: "route-summary",
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "gw-card",
|
||||
Network: "visa",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
if len(graph.Steps) != 2 {
|
||||
t.Fatalf("expected 2 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
assertStep(t, graph.Steps[0], "hop.0.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[1], "hop.0.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
|
||||
t.Fatalf("observe dependency mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_PolicyOverrideByRailPair(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
cardRail := model.RailCardPayout
|
||||
ledgerRail := model.RailLedger
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "crypto-to-card-override",
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailCrypto)},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
|
||||
},
|
||||
Steps: []PolicyStep{
|
||||
{Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail},
|
||||
{Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser},
|
||||
},
|
||||
Success: []PolicyStep{
|
||||
{Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail},
|
||||
},
|
||||
Failure: []PolicyStep{
|
||||
{Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(graph.Steps) != 4 {
|
||||
t.Fatalf("expected 4 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
|
||||
|
||||
if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess {
|
||||
t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy)
|
||||
}
|
||||
if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure {
|
||||
t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) {
|
||||
compiler := New()
|
||||
cardRail := model.RailCardPayout
|
||||
|
||||
on := true
|
||||
external := CustodyExternal
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "generic-external",
|
||||
Enabled: &on,
|
||||
Priority: 1,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Custody: &external},
|
||||
Target: EndpointMatch{Custody: &external},
|
||||
},
|
||||
Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}},
|
||||
},
|
||||
{
|
||||
ID: "specific-crypto-card",
|
||||
Enabled: &on,
|
||||
Priority: 10,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external},
|
||||
},
|
||||
Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(graph.Steps) != 1 {
|
||||
t.Fatalf("expected 1 policy step, got %d", len(graph.Steps))
|
||||
}
|
||||
if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want {
|
||||
t.Fatalf("expected high-priority specific policy, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_IndicativeRejected(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
_, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: "CRYPTO",
|
||||
},
|
||||
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||
Readiness: paymenttypes.QuoteExecutionReadinessIndicative,
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrNotExecutable) {
|
||||
t.Fatalf("expected ErrNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_ValidationErrors(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
enabled := true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "missing intent",
|
||||
in: Input{
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing quote",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing route",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown hop rail",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid policy step action",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "bad-policy",
|
||||
Enabled: &enabled,
|
||||
Priority: 1,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailLedger)},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
|
||||
},
|
||||
Steps: []PolicyStep{
|
||||
{Code: "bad.step", Action: model.RailOperationUnspecified},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := compiler.Compile(tt.in)
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertStep(
|
||||
t *testing.T,
|
||||
step Step,
|
||||
code string,
|
||||
action model.RailOperation,
|
||||
rail model.Rail,
|
||||
visibility model.ReportVisibility,
|
||||
) {
|
||||
t.Helper()
|
||||
if got, want := step.StepCode, code; got != want {
|
||||
t.Fatalf("step code mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.Action, action; got != want {
|
||||
t.Fatalf("step action mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.Rail, rail; got != want {
|
||||
t.Fatalf("step rail mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.Visibility, visibility; got != want {
|
||||
t.Fatalf("step visibility mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func testIntent(kind model.PaymentKind) model.PaymentIntent {
|
||||
return model.PaymentIntent{
|
||||
Kind: kind,
|
||||
Amount: &paymenttypes.Money{
|
||||
Amount: "10",
|
||||
Currency: "USD",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func railPtr(v model.Rail) *model.Rail {
|
||||
return &v
|
||||
}
|
||||
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user