outbox for gateways
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package quote_persistence_service
|
||||
|
||||
import "strconv"
|
||||
|
||||
func cloneBoolPtr(src *bool) *bool {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
value := *src
|
||||
return &value
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package quote_persistence_service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type StatusInput struct {
|
||||
Kind quotationv2.QuoteKind
|
||||
Lifecycle quotationv2.QuoteLifecycle
|
||||
Executable *bool
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
type PersistInput struct {
|
||||
OrganizationID bson.ObjectID
|
||||
QuoteRef string
|
||||
IdempotencyKey string
|
||||
Hash string
|
||||
ExpiresAt time.Time
|
||||
|
||||
Intent *model.PaymentIntent
|
||||
Intents []model.PaymentIntent
|
||||
|
||||
Quote *model.PaymentQuoteSnapshot
|
||||
Quotes []*model.PaymentQuoteSnapshot
|
||||
|
||||
Status *StatusInput
|
||||
Statuses []*StatusInput
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package quote_persistence_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type QuotePersistenceService struct{}
|
||||
|
||||
func New() *QuotePersistenceService {
|
||||
return &QuotePersistenceService{}
|
||||
}
|
||||
|
||||
func (s *QuotePersistenceService) Persist(
|
||||
ctx context.Context,
|
||||
quotesStore quotestorage.QuotesStore,
|
||||
in PersistInput,
|
||||
) (*model.PaymentQuoteRecord, error) {
|
||||
if quotesStore == nil {
|
||||
return nil, merrors.InvalidArgument("quotes store is required")
|
||||
}
|
||||
|
||||
record, err := s.BuildRecord(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *QuotePersistenceService) BuildRecord(in PersistInput) (*model.PaymentQuoteRecord, error) {
|
||||
if in.OrganizationID == bson.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("organization_id is required")
|
||||
}
|
||||
if strings.TrimSpace(in.QuoteRef) == "" {
|
||||
return nil, merrors.InvalidArgument("quote_ref is required")
|
||||
}
|
||||
if strings.TrimSpace(in.IdempotencyKey) == "" {
|
||||
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||
}
|
||||
if strings.TrimSpace(in.Hash) == "" {
|
||||
return nil, merrors.InvalidArgument("hash is required")
|
||||
}
|
||||
if in.ExpiresAt.IsZero() {
|
||||
return nil, merrors.InvalidArgument("expires_at is required")
|
||||
}
|
||||
|
||||
isSingle := in.Quote != nil
|
||||
isBatch := len(in.Quotes) > 0
|
||||
|
||||
if isSingle == isBatch {
|
||||
return nil, merrors.InvalidArgument("exactly one quote shape is required")
|
||||
}
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: strings.TrimSpace(in.QuoteRef),
|
||||
IdempotencyKey: strings.TrimSpace(in.IdempotencyKey),
|
||||
Hash: strings.TrimSpace(in.Hash),
|
||||
ExpiresAt: in.ExpiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(in.OrganizationID)
|
||||
|
||||
if isSingle {
|
||||
if in.Intent == nil {
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
status, err := mapStatusInput(in.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record.Intent = *in.Intent
|
||||
record.Quote = in.Quote
|
||||
record.StatusV2 = status
|
||||
return record, nil
|
||||
}
|
||||
|
||||
if len(in.Intents) == 0 {
|
||||
return nil, merrors.InvalidArgument("intents are required")
|
||||
}
|
||||
if len(in.Intents) != len(in.Quotes) {
|
||||
return nil, merrors.InvalidArgument("intents and quotes count mismatch")
|
||||
}
|
||||
statuses, err := mapStatusInputs(in.Statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(statuses) != len(in.Quotes) {
|
||||
return nil, merrors.InvalidArgument("statuses and quotes count mismatch")
|
||||
}
|
||||
|
||||
record.Intents = in.Intents
|
||||
record.Quotes = in.Quotes
|
||||
record.StatusesV2 = statuses
|
||||
return record, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package quote_persistence_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestPersistSingle(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{}
|
||||
orgID := bson.NewObjectID()
|
||||
trueValue := true
|
||||
|
||||
record, err := svc.Persist(context.Background(), store, PersistInput{
|
||||
OrganizationID: orgID,
|
||||
QuoteRef: "quote-1",
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
Status: &StatusInput{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: &trueValue,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if record == nil {
|
||||
t.Fatalf("expected record")
|
||||
}
|
||||
if store.created == nil {
|
||||
t.Fatalf("expected record to be created")
|
||||
}
|
||||
if store.created.ExecutionNote != "" {
|
||||
t.Fatalf("expected no legacy execution note, got %q", store.created.ExecutionNote)
|
||||
}
|
||||
if store.created.StatusV2 == nil {
|
||||
t.Fatalf("expected v2 status metadata")
|
||||
}
|
||||
if store.created.StatusV2.Kind != model.QuoteKindExecutable {
|
||||
t.Fatalf("unexpected kind: %q", store.created.StatusV2.Kind)
|
||||
}
|
||||
if store.created.StatusV2.Executable == nil || !*store.created.StatusV2.Executable {
|
||||
t.Fatalf("expected executable=true in persisted status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistBatch(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{}
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
record, err := svc.Persist(context.Background(), store, PersistInput{
|
||||
OrganizationID: orgID,
|
||||
QuoteRef: "quote-batch-1",
|
||||
IdempotencyKey: "idem-batch-1",
|
||||
Hash: "hash-batch-1",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "i1"},
|
||||
{Ref: "i2"},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
{QuoteRef: "q2"},
|
||||
},
|
||||
Statuses: []*StatusInput{
|
||||
{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
},
|
||||
{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if record == nil {
|
||||
t.Fatalf("expected record")
|
||||
}
|
||||
if len(record.StatusesV2) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d", len(record.StatusesV2))
|
||||
}
|
||||
if record.StatusesV2[0].BlockReason != model.QuoteBlockReasonRouteUnavailable {
|
||||
t.Fatalf("unexpected first status block reason: %q", record.StatusesV2[0].BlockReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistValidation(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{}
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
_, err := svc.Persist(context.Background(), nil, PersistInput{})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for nil store, got %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Persist(context.Background(), store, PersistInput{
|
||||
OrganizationID: orgID,
|
||||
QuoteRef: "q",
|
||||
IdempotencyKey: "i",
|
||||
Hash: "h",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intent: &model.PaymentIntent{Ref: "intent"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
|
||||
Status: &StatusInput{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: boolPtr(false),
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for executable=false, got %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Persist(context.Background(), store, PersistInput{
|
||||
OrganizationID: orgID,
|
||||
QuoteRef: "q",
|
||||
IdempotencyKey: "i",
|
||||
Hash: "h",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "i1"},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
},
|
||||
Statuses: []*StatusInput{},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for statuses mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeQuotesStore struct {
|
||||
created *model.PaymentQuoteRecord
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (f *fakeQuotesStore) Create(_ context.Context, quote *model.PaymentQuoteRecord) error {
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
f.created = quote
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeQuotesStore) GetByRef(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
func (f *fakeQuotesStore) GetByIdempotencyKey(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package quote_persistence_service
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
|
||||
if input == nil {
|
||||
return nil, merrors.InvalidArgument("status is required")
|
||||
}
|
||||
|
||||
if input.Executable != nil && !*input.Executable {
|
||||
return nil, merrors.InvalidArgument("status.executable must be true when set")
|
||||
}
|
||||
if input.Executable != nil &&
|
||||
input.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return nil, merrors.InvalidArgument("status.executable and status.block_reason are mutually exclusive")
|
||||
}
|
||||
|
||||
return &model.QuoteStatusV2{
|
||||
Kind: mapQuoteKind(input.Kind),
|
||||
Lifecycle: mapQuoteLifecycle(input.Lifecycle),
|
||||
Executable: cloneBoolPtr(input.Executable),
|
||||
BlockReason: mapQuoteBlockReason(input.BlockReason),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make([]*model.QuoteStatusV2, 0, len(inputs))
|
||||
for i, item := range inputs {
|
||||
mapped, err := mapStatusInput(item)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("statuses[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
result = append(result, mapped)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mapQuoteKind(kind quotationv2.QuoteKind) model.QuoteKind {
|
||||
switch kind {
|
||||
case quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE:
|
||||
return model.QuoteKindExecutable
|
||||
case quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE:
|
||||
return model.QuoteKindIndicative
|
||||
default:
|
||||
return model.QuoteKindUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func mapQuoteLifecycle(lifecycle quotationv2.QuoteLifecycle) model.QuoteLifecycle {
|
||||
switch lifecycle {
|
||||
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE:
|
||||
return model.QuoteLifecycleActive
|
||||
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED:
|
||||
return model.QuoteLifecycleExpired
|
||||
default:
|
||||
return model.QuoteLifecycleUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func mapQuoteBlockReason(reason quotationv2.QuoteBlockReason) model.QuoteBlockReason {
|
||||
switch reason {
|
||||
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE:
|
||||
return model.QuoteBlockReasonRouteUnavailable
|
||||
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED:
|
||||
return model.QuoteBlockReasonLimitBlocked
|
||||
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED:
|
||||
return model.QuoteBlockReasonRiskBlocked
|
||||
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY:
|
||||
return model.QuoteBlockReasonInsufficientLiquidity
|
||||
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE:
|
||||
return model.QuoteBlockReasonPriceStale
|
||||
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL:
|
||||
return model.QuoteBlockReasonAmountTooSmall
|
||||
case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE:
|
||||
return model.QuoteBlockReasonAmountTooLarge
|
||||
default:
|
||||
return model.QuoteBlockReasonUnspecified
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user