refactored notificatoin / tgsettle responsibility boundaries
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package agg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Factory builds initial orchestration-v2 payment aggregates.
|
||||
type Factory interface {
|
||||
Create(in Input) (*Payment, error)
|
||||
}
|
||||
|
||||
// State is orchestration runtime state.
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateUnspecified State = "unspecified"
|
||||
StateCreated State = "created"
|
||||
StateExecuting State = "executing"
|
||||
StateNeedsAttention State = "needs_attention"
|
||||
StateSettled State = "settled"
|
||||
StateFailed State = "failed"
|
||||
)
|
||||
|
||||
// StepState is step-level execution state.
|
||||
type StepState string
|
||||
|
||||
const (
|
||||
StepStateUnspecified StepState = "unspecified"
|
||||
StepStatePending StepState = "pending"
|
||||
StepStateRunning StepState = "running"
|
||||
StepStateCompleted StepState = "completed"
|
||||
StepStateFailed StepState = "failed"
|
||||
StepStateNeedsAttention StepState = "needs_attention"
|
||||
StepStateSkipped StepState = "skipped"
|
||||
)
|
||||
|
||||
// StepShell defines one initial step telemetry item.
|
||||
type StepShell struct {
|
||||
StepRef string
|
||||
StepCode string
|
||||
}
|
||||
|
||||
// StepExecution is runtime telemetry for one step.
|
||||
type StepExecution struct {
|
||||
StepRef string
|
||||
StepCode string
|
||||
State StepState
|
||||
Attempt uint32
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
FailureCode string
|
||||
FailureMsg string
|
||||
ExternalRefs []ExternalRef
|
||||
}
|
||||
|
||||
// ExternalRef links step execution to an external operation.
|
||||
type ExternalRef struct {
|
||||
GatewayInstanceID string
|
||||
Kind string
|
||||
Ref string
|
||||
}
|
||||
|
||||
// Input defines payload for creating an initial payment aggregate.
|
||||
type Input struct {
|
||||
OrganizationRef bson.ObjectID
|
||||
IdempotencyKey string
|
||||
QuotationRef string
|
||||
ClientPaymentRef string
|
||||
IntentSnapshot model.PaymentIntent
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
Steps []StepShell
|
||||
}
|
||||
|
||||
// Payment is orchestration-v2 runtime aggregate.
|
||||
type Payment struct {
|
||||
storable.Base
|
||||
pm.OrganizationBoundBase
|
||||
PaymentRef string
|
||||
IdempotencyKey string
|
||||
QuotationRef string
|
||||
ClientPaymentRef string
|
||||
IntentSnapshot model.PaymentIntent
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
State State
|
||||
Version uint64
|
||||
StepExecutions []StepExecution
|
||||
}
|
||||
|
||||
func New() Factory {
|
||||
return &svc{
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
newID: func() bson.ObjectID {
|
||||
return bson.NewObjectID()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package agg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const initialVersion uint64 = 1
|
||||
|
||||
type svc struct {
|
||||
now func() time.Time
|
||||
newID func() bson.ObjectID
|
||||
}
|
||||
|
||||
func (s *svc) Create(in Input) (*Payment, error) {
|
||||
if in.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_id is required")
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(in.IdempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||
}
|
||||
|
||||
quotationRef := strings.TrimSpace(in.QuotationRef)
|
||||
if quotationRef == "" {
|
||||
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||
}
|
||||
|
||||
if isEmptyIntentSnapshot(in.IntentSnapshot) {
|
||||
return nil, merrors.InvalidArgument("intent_snapshot is required")
|
||||
}
|
||||
|
||||
if in.QuoteSnapshot == nil {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot is required")
|
||||
}
|
||||
|
||||
intentSnapshot, err := cloneIntentSnapshot(in.IntentSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quoteSnapshot, err := cloneQuoteSnapshot(in.QuoteSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if quoteSnapshot == nil {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot is required")
|
||||
}
|
||||
|
||||
if quoteRef := strings.TrimSpace(quoteSnapshot.QuoteRef); quoteRef == "" {
|
||||
quoteSnapshot.QuoteRef = quotationRef
|
||||
} else if quoteRef != quotationRef {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.quote_ref must match quotation_ref")
|
||||
}
|
||||
|
||||
stepExecutions, err := buildInitialStepTelemetry(in.Steps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
id := s.newID()
|
||||
|
||||
return &Payment{
|
||||
Base: storable.Base{
|
||||
ID: id,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{
|
||||
OrganizationRef: in.OrganizationRef,
|
||||
},
|
||||
PaymentRef: id.Hex(),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
QuotationRef: quotationRef,
|
||||
ClientPaymentRef: strings.TrimSpace(in.ClientPaymentRef),
|
||||
IntentSnapshot: intentSnapshot,
|
||||
QuoteSnapshot: quoteSnapshot,
|
||||
State: StateCreated,
|
||||
Version: initialVersion,
|
||||
StepExecutions: stepExecutions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
|
||||
if len(shell) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
seenRefs := make(map[string]struct{}, len(shell))
|
||||
out := make([]StepExecution, 0, len(shell))
|
||||
for i := range shell {
|
||||
stepRef := strings.TrimSpace(shell[i].StepRef)
|
||||
if stepRef == "" {
|
||||
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_ref is required")
|
||||
}
|
||||
if _, exists := seenRefs[stepRef]; exists {
|
||||
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_ref must be unique")
|
||||
}
|
||||
seenRefs[stepRef] = struct{}{}
|
||||
|
||||
stepCode := strings.TrimSpace(shell[i].StepCode)
|
||||
if stepCode == "" {
|
||||
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_code is required")
|
||||
}
|
||||
|
||||
out = append(out, StepExecution{
|
||||
StepRef: stepRef,
|
||||
StepCode: stepCode,
|
||||
State: StepStatePending,
|
||||
Attempt: 1,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
|
||||
var dst model.PaymentIntent
|
||||
if err := bsonClone(src, &dst); err != nil {
|
||||
return model.PaymentIntent{}, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSnapshot, error) {
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
dst := &model.PaymentQuoteSnapshot{}
|
||||
if err := bsonClone(src, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func bsonClone(src any, dst any) error {
|
||||
data, err := bson.Marshal(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bson.Unmarshal(data, dst)
|
||||
}
|
||||
|
||||
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
|
||||
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
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,267 @@
|
||||
package agg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestCreate_OK(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
orgID := bson.NewObjectID()
|
||||
paymentID := bson.NewObjectID()
|
||||
|
||||
factory := &svc{
|
||||
now: func() time.Time { return now },
|
||||
newID: func() bson.ObjectID {
|
||||
return paymentID
|
||||
},
|
||||
}
|
||||
|
||||
intent := model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Kind: model.PaymentKindPayout,
|
||||
}
|
||||
quote := &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "",
|
||||
}
|
||||
|
||||
payment, err := factory.Create(Input{
|
||||
OrganizationRef: orgID,
|
||||
IdempotencyKey: " idem-1 ",
|
||||
QuotationRef: " quote-1 ",
|
||||
ClientPaymentRef: " client-1 ",
|
||||
IntentSnapshot: intent,
|
||||
QuoteSnapshot: quote,
|
||||
Steps: []StepShell{
|
||||
{StepRef: " s1 ", StepCode: " reserve_funds "},
|
||||
{StepRef: "s2", StepCode: "submit_gateway"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if payment == nil {
|
||||
t.Fatal("expected aggregate")
|
||||
}
|
||||
|
||||
if got, want := payment.ID, paymentID; got != want {
|
||||
t.Fatalf("id mismatch: got=%s want=%s", got.Hex(), want.Hex())
|
||||
}
|
||||
if got, want := payment.PaymentRef, paymentID.Hex(); got != want {
|
||||
t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.OrganizationRef, orgID; got != want {
|
||||
t.Fatalf("organization mismatch: got=%s want=%s", got.Hex(), want.Hex())
|
||||
}
|
||||
if got, want := payment.IdempotencyKey, "idem-1"; got != want {
|
||||
t.Fatalf("idempotency_key mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.QuotationRef, "quote-1"; got != want {
|
||||
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.ClientPaymentRef, "client-1"; got != want {
|
||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.State, StateCreated; got != want {
|
||||
t.Fatalf("state mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.Version, initialVersion; got != want {
|
||||
t.Fatalf("version mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := payment.CreatedAt, now; got != want {
|
||||
t.Fatalf("created_at mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := payment.UpdatedAt, now; got != want {
|
||||
t.Fatalf("updated_at mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
|
||||
if got, want := payment.IntentSnapshot.Ref, "intent-1"; got != want {
|
||||
t.Fatalf("intent_snapshot.ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if payment.QuoteSnapshot == nil {
|
||||
t.Fatal("expected quote_snapshot")
|
||||
}
|
||||
if got, want := payment.QuoteSnapshot.QuoteRef, "quote-1"; got != want {
|
||||
t.Fatalf("quote_snapshot.quote_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
|
||||
if len(payment.StepExecutions) != 2 {
|
||||
t.Fatalf("expected 2 step executions, got %d", len(payment.StepExecutions))
|
||||
}
|
||||
if payment.StepExecutions[0].StepRef != "s1" || payment.StepExecutions[0].StepCode != "reserve_funds" {
|
||||
t.Fatalf("unexpected first step: %+v", payment.StepExecutions[0])
|
||||
}
|
||||
if payment.StepExecutions[0].State != StepStatePending || payment.StepExecutions[0].Attempt != 1 {
|
||||
t.Fatalf("unexpected first step shell state: %+v", payment.StepExecutions[0])
|
||||
}
|
||||
|
||||
// Verify immutable snapshot semantics by ensuring clones were created.
|
||||
payment.IntentSnapshot.Ref = "changed"
|
||||
payment.QuoteSnapshot.QuoteRef = "changed"
|
||||
if intent.Ref != "intent-1" {
|
||||
t.Fatalf("expected original intent unchanged, got %q", intent.Ref)
|
||||
}
|
||||
if quote.QuoteRef != "" {
|
||||
t.Fatalf("expected original quote unchanged, got %q", quote.QuoteRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_QuoteRefMismatch(t *testing.T) {
|
||||
factory := New()
|
||||
|
||||
_, err := factory.Create(Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
Amount: testMoney(),
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "quote-2",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_NoStepsProducesEmptyShell(t *testing.T) {
|
||||
factory := New()
|
||||
|
||||
payment, err := factory.Create(Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
Amount: testMoney(),
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if len(payment.StepExecutions) != 0 {
|
||||
t.Fatalf("expected empty step telemetry shell, got %d", len(payment.StepExecutions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_InputValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "missing organization_id",
|
||||
in: Input{
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing idempotency_key",
|
||||
in: Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing quotation_ref",
|
||||
in: Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing intent_snapshot",
|
||||
in: Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing quote_snapshot",
|
||||
in: Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "step missing ref",
|
||||
in: Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
Steps: []StepShell{
|
||||
{StepRef: " ", StepCode: "code"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "step missing code",
|
||||
in: Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
Steps: []StepShell{
|
||||
{StepRef: "s1", StepCode: " "},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "step ref must be unique",
|
||||
in: Input{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
Steps: []StepShell{
|
||||
{StepRef: "s1", StepCode: "code-1"},
|
||||
{StepRef: "s1", StepCode: "code-2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
factory := New()
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := factory.Create(tt.in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testMoney() *modelMoney {
|
||||
return &modelMoney{Amount: "10", Currency: "USD"}
|
||||
}
|
||||
|
||||
// modelMoney is a minimal compatibility shim for tests without depending on payments/types constructors.
|
||||
type modelMoney = paymenttypes.Money
|
||||
@@ -0,0 +1,7 @@
|
||||
package idem
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package idem
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
const hashSep = "\x1f"
|
||||
|
||||
func (s *svc) Fingerprint(in FPInput) (string, error) {
|
||||
orgRef := strings.ToLower(strings.TrimSpace(in.OrganizationRef))
|
||||
if orgRef == "" {
|
||||
return "", merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
quotationRef := strings.TrimSpace(in.QuotationRef)
|
||||
if quotationRef == "" {
|
||||
return "", merrors.InvalidArgument("quotation_ref is required")
|
||||
}
|
||||
clientPaymentRef := strings.TrimSpace(in.ClientPaymentRef)
|
||||
|
||||
payload := strings.Join([]string{
|
||||
"org=" + orgRef,
|
||||
"quote=" + quotationRef,
|
||||
"client=" + clientPaymentRef,
|
||||
}, hashSep)
|
||||
|
||||
return hashBytes([]byte(payload)), nil
|
||||
}
|
||||
|
||||
func hashBytes(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package idem
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Store is the minimal payment store contract required for idempotency handling.
|
||||
type Store interface {
|
||||
Create(ctx context.Context, payment *model.Payment) error
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
|
||||
}
|
||||
|
||||
// Service handles execute-payment idempotency concerns for v2 orchestrator flow.
|
||||
type Service interface {
|
||||
Fingerprint(in FPInput) (string, error)
|
||||
TryReuse(ctx context.Context, store Store, in ReuseInput) (*model.Payment, bool, error)
|
||||
CreateOrReuse(ctx context.Context, store Store, in CreateInput) (*model.Payment, bool, error)
|
||||
}
|
||||
|
||||
// FPInput is the business payload used for idempotency fingerprinting.
|
||||
type FPInput struct {
|
||||
OrganizationRef string
|
||||
QuotationRef string
|
||||
ClientPaymentRef string
|
||||
}
|
||||
|
||||
// ReuseInput defines lookup and comparison inputs for idempotency reuse checks.
|
||||
type ReuseInput struct {
|
||||
OrganizationID bson.ObjectID
|
||||
IdempotencyKey string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// CreateInput wraps create operation with reuse-check context for duplicate races.
|
||||
type CreateInput struct {
|
||||
Payment *model.Payment
|
||||
Reuse ReuseInput
|
||||
}
|
||||
|
||||
func New() Service {
|
||||
return &svc{}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package idem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
const reqHashMetaKey = "_orchestrator_v2_req_hash"
|
||||
|
||||
type svc struct{}
|
||||
|
||||
func (s *svc) TryReuse(
|
||||
ctx context.Context,
|
||||
store Store,
|
||||
in ReuseInput,
|
||||
) (*model.Payment, bool, error) {
|
||||
if store == nil {
|
||||
return nil, false, merrors.InvalidArgument("payments store is required")
|
||||
}
|
||||
|
||||
idempotencyKey, fingerprint, err := validateReuseInput(in)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
payment, err := store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if payment == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if paymentReqHash(payment) != fingerprint {
|
||||
return nil, false, ErrIdempotencyParamMismatch
|
||||
}
|
||||
|
||||
return payment, true, nil
|
||||
}
|
||||
|
||||
func (s *svc) CreateOrReuse(
|
||||
ctx context.Context,
|
||||
store Store,
|
||||
in CreateInput,
|
||||
) (*model.Payment, bool, error) {
|
||||
if store == nil {
|
||||
return nil, false, merrors.InvalidArgument("payments store is required")
|
||||
}
|
||||
if in.Payment == nil {
|
||||
return nil, false, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
|
||||
_, fingerprint, err := validateReuseInput(in.Reuse)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
setPaymentReqHash(in.Payment, fingerprint)
|
||||
|
||||
if err := store.Create(ctx, in.Payment); err != nil {
|
||||
if !errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
payment, reused, reuseErr := s.TryReuse(ctx, store, in.Reuse)
|
||||
if reuseErr != nil {
|
||||
return nil, false, reuseErr
|
||||
}
|
||||
if reused {
|
||||
return payment, true, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return in.Payment, false, nil
|
||||
}
|
||||
|
||||
func validateReuseInput(in ReuseInput) (string, string, error) {
|
||||
if in.OrganizationID.IsZero() {
|
||||
return "", "", merrors.InvalidArgument("organization_id is required")
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(in.IdempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return "", "", merrors.InvalidArgument("idempotency_key is required")
|
||||
}
|
||||
|
||||
fingerprint := strings.TrimSpace(in.Fingerprint)
|
||||
if fingerprint == "" {
|
||||
return "", "", merrors.InvalidArgument("fingerprint is required")
|
||||
}
|
||||
|
||||
return idempotencyKey, fingerprint, nil
|
||||
}
|
||||
|
||||
func paymentReqHash(payment *model.Payment) string {
|
||||
if payment == nil || payment.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(payment.Metadata[reqHashMetaKey])
|
||||
}
|
||||
|
||||
func setPaymentReqHash(payment *model.Payment, hash string) {
|
||||
if payment == nil {
|
||||
return
|
||||
}
|
||||
hash = strings.TrimSpace(hash)
|
||||
if hash == "" {
|
||||
return
|
||||
}
|
||||
if payment.Metadata == nil {
|
||||
payment.Metadata = map[string]string{}
|
||||
}
|
||||
payment.Metadata[reqHashMetaKey] = hash
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package idem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestFingerprint_StableAndTrimmed(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
a, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
|
||||
QuotationRef: " quote-1 ",
|
||||
ClientPaymentRef: " client-1 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
b, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
|
||||
QuotationRef: "quote-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
base, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
|
||||
diffQuote, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-2",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffQuote {
|
||||
t.Fatalf("expected different fingerprint for different quotation_ref")
|
||||
}
|
||||
|
||||
diffClient, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
ClientPaymentRef: "client-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffClient {
|
||||
t.Fatalf("expected different fingerprint for different client_payment_ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_RequiresBusinessFields(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
if _, err := svc.Fingerprint(FPInput{
|
||||
QuotationRef: "quote-1",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty organization_ref")
|
||||
}
|
||||
|
||||
if _, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty quotation_ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryReuse_NotFound(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
},
|
||||
}
|
||||
|
||||
payment, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
Fingerprint: "hash-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TryReuse returned error: %v", err)
|
||||
}
|
||||
if reused {
|
||||
t.Fatal("expected reused=false")
|
||||
}
|
||||
if payment != nil {
|
||||
t.Fatal("expected nil payment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryReuse_ParamMismatch(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
|
||||
return &model.Payment{
|
||||
Metadata: map[string]string{
|
||||
reqHashMetaKey: "stored-hash",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := svc.TryReuse(context.Background(), store, ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
Fingerprint: "new-hash",
|
||||
})
|
||||
if !errors.Is(err, ErrIdempotencyParamMismatch) {
|
||||
t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryReuse_Success(t *testing.T) {
|
||||
svc := New()
|
||||
existing := &model.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
Metadata: map[string]string{
|
||||
reqHashMetaKey: "hash-1",
|
||||
},
|
||||
}
|
||||
store := &fakeStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
|
||||
return existing, nil
|
||||
},
|
||||
}
|
||||
|
||||
payment, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
Fingerprint: "hash-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TryReuse returned error: %v", err)
|
||||
}
|
||||
if !reused {
|
||||
t.Fatal("expected reused=true")
|
||||
}
|
||||
if payment != existing {
|
||||
t.Fatalf("expected existing payment, got %+v", payment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrReuse_CreateSuccess(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeStore{
|
||||
createFn: func(context.Context, *model.Payment) error { return nil },
|
||||
}
|
||||
newPayment := &model.Payment{
|
||||
PaymentRef: "pay-new",
|
||||
}
|
||||
|
||||
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Payment: newPayment,
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
Fingerprint: "hash-1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrReuse returned error: %v", err)
|
||||
}
|
||||
if reused {
|
||||
t.Fatal("expected reused=false")
|
||||
}
|
||||
if got != newPayment {
|
||||
t.Fatalf("expected created payment, got %+v", got)
|
||||
}
|
||||
if got.Metadata == nil || got.Metadata[reqHashMetaKey] != "hash-1" {
|
||||
t.Fatalf("expected payment metadata hash, got %+v", got.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrReuse_DuplicateReturnsExisting(t *testing.T) {
|
||||
svc := New()
|
||||
existing := &model.Payment{
|
||||
PaymentRef: "pay-existing",
|
||||
Metadata: map[string]string{
|
||||
reqHashMetaKey: "hash-1",
|
||||
},
|
||||
}
|
||||
store := &fakeStore{
|
||||
createFn: func(context.Context, *model.Payment) error { return storage.ErrDuplicatePayment },
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
|
||||
return existing, nil
|
||||
},
|
||||
}
|
||||
newPayment := &model.Payment{PaymentRef: "pay-new"}
|
||||
|
||||
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Payment: newPayment,
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
Fingerprint: "hash-1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrReuse returned error: %v", err)
|
||||
}
|
||||
if !reused {
|
||||
t.Fatal("expected reused=true")
|
||||
}
|
||||
if got != existing {
|
||||
t.Fatalf("expected existing payment, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrReuse_DuplicateParamMismatch(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeStore{
|
||||
createFn: func(context.Context, *model.Payment) error { return storage.ErrDuplicatePayment },
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
|
||||
return &model.Payment{
|
||||
Metadata: map[string]string{
|
||||
reqHashMetaKey: "stored-hash",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Payment: &model.Payment{PaymentRef: "pay-new"},
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
Fingerprint: "new-hash",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrIdempotencyParamMismatch) {
|
||||
t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeStore{
|
||||
createFn: func(context.Context, *model.Payment) error { return storage.ErrDuplicatePayment },
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Payment: &model.Payment{PaymentRef: "pay-new"},
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
Fingerprint: "hash-1",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
t.Fatalf("expected ErrDuplicatePayment, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
createFn func(ctx context.Context, payment *model.Payment) error
|
||||
getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
|
||||
}
|
||||
|
||||
func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error {
|
||||
if f.createFn == nil {
|
||||
return nil
|
||||
}
|
||||
return f.createFn(ctx, payment)
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) {
|
||||
if f.getByIdempotencyKeyFn == nil {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package qsnap
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrQuoteNotFound = errors.New("quotation_ref not found")
|
||||
ErrQuoteExpired = errors.New("quotation_ref expired")
|
||||
ErrQuoteNotExecutable = errors.New("quotation_ref is not executable")
|
||||
ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch")
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package qsnap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Store is the minimal quote store contract required by the resolver.
|
||||
type Store interface {
|
||||
GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||
}
|
||||
|
||||
// Resolver resolves a quotation reference into canonical execution snapshots.
|
||||
type Resolver interface {
|
||||
Resolve(ctx context.Context, store Store, in Input) (*Output, error)
|
||||
}
|
||||
|
||||
// Input defines lookup scope for quotation resolution.
|
||||
type Input struct {
|
||||
OrganizationID bson.ObjectID
|
||||
QuotationRef string
|
||||
}
|
||||
|
||||
// Output contains extracted canonical snapshots for execution.
|
||||
type Output struct {
|
||||
QuotationRef string
|
||||
IntentSnapshot model.PaymentIntent
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
}
|
||||
|
||||
func New() Resolver {
|
||||
return &svc{
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package qsnap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 svc struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *svc) Resolve(
|
||||
ctx context.Context,
|
||||
store Store,
|
||||
in Input,
|
||||
) (*Output, error) {
|
||||
if store == nil {
|
||||
return nil, merrors.InvalidArgument("quotes store is required")
|
||||
}
|
||||
if in.OrganizationID.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_id is required")
|
||||
}
|
||||
|
||||
quoteRef := strings.TrimSpace(in.QuotationRef)
|
||||
if quoteRef == "" {
|
||||
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||
}
|
||||
|
||||
record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, quotestorage.ErrQuoteNotFound) || errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, ErrQuoteNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, ErrQuoteNotFound
|
||||
}
|
||||
|
||||
if err := ensureExecutable(record, s.now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intentSnapshot, err := extractIntentSnapshot(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quoteSnapshot, err := extractQuoteSnapshot(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outputRef := strings.TrimSpace(record.QuoteRef)
|
||||
if outputRef == "" {
|
||||
outputRef = quoteRef
|
||||
}
|
||||
if quoteSnapshot != nil && strings.TrimSpace(quoteSnapshot.QuoteRef) == "" {
|
||||
quoteSnapshot.QuoteRef = outputRef
|
||||
}
|
||||
|
||||
return &Output{
|
||||
QuotationRef: outputRef,
|
||||
IntentSnapshot: intentSnapshot,
|
||||
QuoteSnapshot: quoteSnapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error {
|
||||
if record == nil {
|
||||
return ErrQuoteNotFound
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && now.After(record.ExpiresAt.UTC()) {
|
||||
return ErrQuoteExpired
|
||||
}
|
||||
|
||||
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
|
||||
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
|
||||
}
|
||||
|
||||
switch status.State {
|
||||
case model.QuoteStateExecutable:
|
||||
return nil
|
||||
case model.QuoteStateExpired:
|
||||
return ErrQuoteExpired
|
||||
case model.QuoteStateBlocked:
|
||||
reason := strings.TrimSpace(string(status.BlockReason))
|
||||
if reason != "" && reason != string(model.QuoteBlockReasonUnspecified) {
|
||||
return fmt.Errorf("%w: blocked (%s)", ErrQuoteNotExecutable, reason)
|
||||
}
|
||||
return fmt.Errorf("%w: blocked", ErrQuoteNotExecutable)
|
||||
case model.QuoteStateIndicative:
|
||||
return fmt.Errorf("%w: indicative", ErrQuoteNotExecutable)
|
||||
default:
|
||||
state := strings.TrimSpace(string(status.State))
|
||||
if state == "" {
|
||||
return fmt.Errorf("%w: unspecified status", ErrQuoteNotExecutable)
|
||||
}
|
||||
return fmt.Errorf("%w: state=%s", ErrQuoteNotExecutable, state)
|
||||
}
|
||||
}
|
||||
|
||||
func extractSingleStatus(record *model.PaymentQuoteRecord) (*model.QuoteStatusV2, error) {
|
||||
if record == nil {
|
||||
return nil, ErrQuoteShapeMismatch
|
||||
}
|
||||
if len(record.StatusesV2) > 0 {
|
||||
if len(record.StatusesV2) != 1 {
|
||||
return nil, fmt.Errorf("%w: expected single status", ErrQuoteShapeMismatch)
|
||||
}
|
||||
if record.StatusesV2[0] == nil {
|
||||
return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch)
|
||||
}
|
||||
return record.StatusesV2[0], nil
|
||||
}
|
||||
return record.StatusV2, nil
|
||||
}
|
||||
|
||||
func extractIntentSnapshot(record *model.PaymentQuoteRecord) (model.PaymentIntent, error) {
|
||||
if record == nil {
|
||||
return model.PaymentIntent{}, ErrQuoteShapeMismatch
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return cloneQuoteSnapshot(record.Quotes[0])
|
||||
}
|
||||
return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch)
|
||||
}
|
||||
|
||||
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
|
||||
var dst model.PaymentIntent
|
||||
if err := bsonClone(src, &dst); err != nil {
|
||||
return model.PaymentIntent{}, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSnapshot, error) {
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
dst := &model.PaymentQuoteSnapshot{}
|
||||
if err := bsonClone(src, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func bsonClone(src any, dst any) error {
|
||||
data, err := bson.Marshal(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bson.Unmarshal(data, dst)
|
||||
}
|
||||
|
||||
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
|
||||
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package qsnap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestResolve_SingleShapeOK(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "stored-quote-ref",
|
||||
Intent: model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "",
|
||||
},
|
||||
StatusV2: &model.QuoteStatusV2{
|
||||
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: "stored-quote-ref",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := out.QuotationRef, "stored-quote-ref"; got != want {
|
||||
t.Fatalf("quotation_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)
|
||||
}
|
||||
if out.QuoteSnapshot == nil {
|
||||
t.Fatal("expected quote snapshot")
|
||||
}
|
||||
if got, want := out.QuoteSnapshot.QuoteRef, "stored-quote-ref"; got != want {
|
||||
t.Fatalf("quote_snapshot.quote_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
|
||||
out.QuoteSnapshot.QuoteRef = "changed"
|
||||
if record.Quote.QuoteRef != "" {
|
||||
t.Fatalf("expected stored quote snapshot to be unchanged, got %q", record.Quote.QuoteRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ArrayShapeOK(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "batch-like-single",
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "intent-1", Kind: model.PaymentKindInternalTransfer},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "snapshot-ref"},
|
||||
},
|
||||
StatusesV2: []*model.QuoteStatusV2{
|
||||
{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-like-single",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
|
||||
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if out.QuoteSnapshot == nil {
|
||||
t.Fatal("expected quote snapshot")
|
||||
}
|
||||
if got, want := out.QuoteSnapshot.QuoteRef, "snapshot-ref"; got != want {
|
||||
t.Fatalf("quote_snapshot.quote_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotFound(t *testing.T) {
|
||||
resolver := New()
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotFound) {
|
||||
t.Fatalf("expected ErrQuoteNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Expired(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",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
StatusV2: &model.QuoteStatusV2{
|
||||
State: model.QuoteStateExecutable,
|
||||
},
|
||||
ExpiresAt: now.Add(-time.Second),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteExpired) {
|
||||
t.Fatalf("expected ErrQuoteExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotExecutableState(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",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
StatusV2: &model.QuoteStatusV2{
|
||||
State: model.QuoteStateBlocked,
|
||||
BlockReason: model.QuoteBlockReasonRouteUnavailable,
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotExecutable) {
|
||||
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotExecutableExecutionNote(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",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
ExecutionNote: "quote will not be executed",
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotExecutable) {
|
||||
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ShapeMismatch(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{
|
||||
{Kind: model.PaymentKindPayout},
|
||||
{Kind: model.PaymentKindPayout},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{},
|
||||
{},
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteShapeMismatch) {
|
||||
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_InputValidation(t *testing.T) {
|
||||
resolver := New()
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store Store
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "nil store",
|
||||
store: nil,
|
||||
in: Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: "quote-ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty org id",
|
||||
store: &fakeStore{},
|
||||
in: Input{
|
||||
QuotationRef: "quote-ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty quotation ref",
|
||||
store: &fakeStore{},
|
||||
in: Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: " ",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := resolver.Resolve(context.Background(), tt.store, tt.in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
if f.getByRefFn == nil {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
return f.getByRefFn(ctx, orgRef, quoteRef)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package reqval
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
// Validator validates execute-payment inputs and returns a normalized context.
|
||||
type Validator interface {
|
||||
Validate(req *Req) (*Ctx, error)
|
||||
}
|
||||
|
||||
// Req is the execute-payment request shape used by the validator module.
|
||||
// It is intentionally transport-agnostic, so it can be mapped from proto later.
|
||||
type Req struct {
|
||||
Meta *Meta
|
||||
QuotationRef string
|
||||
ClientPaymentRef string
|
||||
}
|
||||
|
||||
// Meta carries organization and trace context fields required for validation.
|
||||
type Meta struct {
|
||||
OrganizationRef string
|
||||
Trace *Trace
|
||||
}
|
||||
|
||||
// Trace carries trace-bound idempotency key for execution requests.
|
||||
type Trace struct {
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
// Ctx is the normalized output used by downstream execute-payment flow.
|
||||
type Ctx struct {
|
||||
OrganizationRef string
|
||||
OrganizationID bson.ObjectID
|
||||
IdempotencyKey string
|
||||
QuotationRef string
|
||||
ClientPaymentRef string
|
||||
}
|
||||
|
||||
func New() Validator {
|
||||
return &svc{}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package reqval
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
maxIdempotencyKeyLen = 256
|
||||
maxQuotationRefLen = 128
|
||||
maxClientRefLen = 128
|
||||
)
|
||||
|
||||
var refTokenRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*$`)
|
||||
|
||||
type svc struct{}
|
||||
|
||||
func (s *svc) Validate(req *Req) (*Ctx, error) {
|
||||
if req == nil {
|
||||
return nil, merrors.InvalidArgument("request is required")
|
||||
}
|
||||
if req.Meta == nil {
|
||||
return nil, merrors.InvalidArgument("meta is required")
|
||||
}
|
||||
|
||||
orgRef := strings.TrimSpace(req.Meta.OrganizationRef)
|
||||
if orgRef == "" {
|
||||
return nil, merrors.InvalidArgument("meta.organization_ref is required")
|
||||
}
|
||||
orgID, err := bson.ObjectIDFromHex(orgRef)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("meta.organization_ref must be a valid objectID")
|
||||
}
|
||||
|
||||
if req.Meta.Trace == nil {
|
||||
return nil, merrors.InvalidArgument("meta.trace is required")
|
||||
}
|
||||
|
||||
idempotencyKey, err := validateRefToken("meta.trace.idempotency_key", req.Meta.Trace.IdempotencyKey, maxIdempotencyKeyLen, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quotationRef, err := validateRefToken("quotation_ref", req.QuotationRef, maxQuotationRefLen, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Ctx{
|
||||
OrganizationRef: orgRef,
|
||||
OrganizationID: orgID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
QuotationRef: quotationRef,
|
||||
ClientPaymentRef: clientPaymentRef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateRefToken(field, value string, maxLen int, required bool) (string, error) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
if required {
|
||||
return "", merrors.InvalidArgument(field + " is required")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if maxLen > 0 && len(normalized) > maxLen {
|
||||
return "", merrors.InvalidArgument(field + " is too long")
|
||||
}
|
||||
if !refTokenRe.MatchString(normalized) {
|
||||
return "", merrors.InvalidArgument(field + " has invalid format")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package reqval
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestValidate_OK(t *testing.T) {
|
||||
v := New()
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
ctx, err := v.Validate(&Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: " " + orgID.Hex() + " ",
|
||||
Trace: &Trace{
|
||||
IdempotencyKey: " idem-1:alpha ",
|
||||
},
|
||||
},
|
||||
QuotationRef: " quote-ref-1 ",
|
||||
ClientPaymentRef: " client.ref-1 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Validate returned error: %v", err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("expected ctx")
|
||||
}
|
||||
if got, want := ctx.OrganizationRef, orgID.Hex(); got != want {
|
||||
t.Fatalf("organization_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := ctx.OrganizationID, orgID; got != want {
|
||||
t.Fatalf("organization_id mismatch: got=%s want=%s", got.Hex(), want.Hex())
|
||||
}
|
||||
if got, want := ctx.IdempotencyKey, "idem-1:alpha"; got != want {
|
||||
t.Fatalf("idempotency_key mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
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.ClientPaymentRef, "client.ref-1"; got != want {
|
||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ClientPaymentRefOptional(t *testing.T) {
|
||||
v := New()
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
ctx, err := v.Validate(&Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
Trace: &Trace{
|
||||
IdempotencyKey: "idem-1",
|
||||
},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Validate returned error: %v", err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("expected ctx")
|
||||
}
|
||||
if ctx.ClientPaymentRef != "" {
|
||||
t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Errors(t *testing.T) {
|
||||
orgID := bson.NewObjectID().Hex()
|
||||
tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen)
|
||||
tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen)
|
||||
tooLongClient := "c" + strings.Repeat("a", maxClientRefLen)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *Req
|
||||
}{
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
},
|
||||
{
|
||||
name: "nil meta",
|
||||
req: &Req{},
|
||||
},
|
||||
{
|
||||
name: "empty org ref",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid org ref",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: "not-an-object-id",
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil trace",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty idempotency key",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: " "},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too long idempotency key",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: tooLongIdem},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad idempotency key shape",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem key"},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty quotation ref",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: " ",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too long quotation ref",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: tooLongQuote,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad quotation ref shape",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: "quote ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too long client payment ref",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
ClientPaymentRef: tooLongClient,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad client payment ref shape",
|
||||
req: &Req{
|
||||
Meta: &Meta{
|
||||
OrganizationRef: orgID,
|
||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
||||
},
|
||||
QuotationRef: "quote-1",
|
||||
ClientPaymentRef: "client payment ref",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
v := New()
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, err := v.Validate(tt.req)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got ctx=%+v", ctx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user