outbox for gateways

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

View File

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

View File

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

View File

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

View File

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

View File

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