+source currency pick fix +fx side propagation

This commit is contained in:
Stephan D
2026-02-26 02:39:48 +01:00
parent 008427483c
commit 70b1c2a9cc
73 changed files with 2123 additions and 656 deletions

View File

@@ -13,19 +13,18 @@ type StatusInput struct {
BlockReason quotationv2.QuoteBlockReason
}
type PersistItemInput struct {
Intent *model.PaymentIntent
Quote *model.PaymentQuoteSnapshot
Status *StatusInput
}
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
RequestShape model.QuoteRequestShape
Items []PersistItemInput
}

View File

@@ -52,53 +52,46 @@ func (s *QuotePersistenceService) BuildRecord(in PersistInput) (*model.PaymentQu
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")
switch in.RequestShape {
case model.QuoteRequestShapeSingle:
if len(in.Items) != 1 {
return nil, merrors.InvalidArgument("single shape requires exactly one item")
}
case model.QuoteRequestShapeBatch:
if len(in.Items) == 0 {
return nil, merrors.InvalidArgument("batch shape requires at least one item")
}
default:
return nil, merrors.InvalidArgument("request_shape is required")
}
record := &model.PaymentQuoteRecord{
QuoteRef: strings.TrimSpace(in.QuoteRef),
IdempotencyKey: strings.TrimSpace(in.IdempotencyKey),
RequestShape: in.RequestShape,
Hash: strings.TrimSpace(in.Hash),
ExpiresAt: in.ExpiresAt,
Items: make([]*model.PaymentQuoteItemV2, 0, len(in.Items)),
}
record.SetID(bson.NewObjectID())
record.SetOrganizationRef(in.OrganizationID)
if isSingle {
if in.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
for idx, item := range in.Items {
if item.Intent == nil {
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].intent is required")
}
status, err := mapStatusInput(in.Status)
if item.Quote == nil {
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].quote is required")
}
status, err := mapStatusInput(item.Status)
if err != nil {
return nil, err
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "]." + err.Error())
}
record.Intent = *in.Intent
record.Quote = in.Quote
record.StatusV2 = status
return record, nil
record.Items = append(record.Items, &model.PaymentQuoteItemV2{
Intent: item.Intent,
Quote: item.Quote,
Status: status,
})
}
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

@@ -24,12 +24,17 @@ func TestPersistSingle(t *testing.T) {
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{
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
RequestShape: model.QuoteRequestShapeSingle,
Items: []PersistItemInput{
{
Intent: &model.PaymentIntent{Ref: "intent-1"},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: "quote-1",
},
Status: &StatusInput{
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
},
},
},
})
if err != nil {
@@ -41,17 +46,20 @@ func TestPersistSingle(t *testing.T) {
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.RequestShape != model.QuoteRequestShapeSingle {
t.Fatalf("unexpected request shape: %q", store.created.RequestShape)
}
if store.created.StatusV2 == nil {
t.Fatalf("expected v2 status metadata")
if len(store.created.Items) != 1 || store.created.Items[0] == nil {
t.Fatalf("expected single persisted item")
}
if store.created.StatusV2.State != model.QuoteStateExecutable {
t.Fatalf("unexpected state: %q", store.created.StatusV2.State)
if store.created.Items[0].Status == nil {
t.Fatalf("expected item status metadata")
}
if store.created.StatusV2.BlockReason != model.QuoteBlockReasonUnspecified {
t.Fatalf("unexpected block_reason: %q", store.created.StatusV2.BlockReason)
if store.created.Items[0].Status.State != model.QuoteStateExecutable {
t.Fatalf("unexpected state: %q", store.created.Items[0].Status.State)
}
if store.created.Items[0].Status.BlockReason != model.QuoteBlockReasonUnspecified {
t.Fatalf("unexpected block_reason: %q", store.created.Items[0].Status.BlockReason)
}
}
@@ -66,21 +74,22 @@ func TestPersistBatch(t *testing.T) {
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{
RequestShape: model.QuoteRequestShapeBatch,
Items: []PersistItemInput{
{
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
Intent: &model.PaymentIntent{Ref: "i1"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
Status: &StatusInput{
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
},
},
{
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
Intent: &model.PaymentIntent{Ref: "i2"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
Status: &StatusInput{
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
},
},
},
})
@@ -90,11 +99,14 @@ func TestPersistBatch(t *testing.T) {
if record == nil {
t.Fatalf("expected record")
}
if len(record.StatusesV2) != 2 {
t.Fatalf("expected 2 statuses, got %d", len(record.StatusesV2))
if record.RequestShape != model.QuoteRequestShapeBatch {
t.Fatalf("unexpected request shape: %q", record.RequestShape)
}
if record.StatusesV2[0].BlockReason != model.QuoteBlockReasonRouteUnavailable {
t.Fatalf("unexpected first status block reason: %q", record.StatusesV2[0].BlockReason)
if len(record.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(record.Items))
}
if record.Items[0].Status == nil || record.Items[0].Status.BlockReason != model.QuoteBlockReasonRouteUnavailable {
t.Fatalf("unexpected first status block reason")
}
}
@@ -114,11 +126,16 @@ func TestPersistValidation(t *testing.T) {
IdempotencyKey: "i",
Hash: "h",
ExpiresAt: time.Now().Add(time.Minute),
Intent: &model.PaymentIntent{Ref: "intent"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
Status: &StatusInput{
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
RequestShape: model.QuoteRequestShapeSingle,
Items: []PersistItemInput{
{
Intent: &model.PaymentIntent{Ref: "intent"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
Status: &StatusInput{
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
},
},
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
@@ -131,16 +148,22 @@ func TestPersistValidation(t *testing.T) {
IdempotencyKey: "i",
Hash: "h",
ExpiresAt: time.Now().Add(time.Minute),
Intents: []model.PaymentIntent{
{Ref: "i1"},
RequestShape: model.QuoteRequestShapeSingle,
Items: []PersistItemInput{
{
Intent: &model.PaymentIntent{Ref: "i1"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE},
},
{
Intent: &model.PaymentIntent{Ref: "i2"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE},
},
},
Quotes: []*model.PaymentQuoteSnapshot{
{QuoteRef: "q1"},
},
Statuses: []*StatusInput{},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for statuses mismatch, got %v", err)
t.Fatalf("expected invalid argument for single shape with multiple items, got %v", err)
}
}

View File

@@ -28,22 +28,6 @@ func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
}, 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 mapQuoteState(state quotationv2.QuoteState) model.QuoteState {
switch state {
case quotationv2.QuoteState_QUOTE_STATE_INDICATIVE: