+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

@@ -43,21 +43,25 @@ const (
// StepShell defines one initial step telemetry item.
type StepShell struct {
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
}
// StepExecution is runtime telemetry for one step.
type StepExecution struct {
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
State StepState `bson:"state" json:"state"`
Attempt uint32 `bson:"attempt" json:"attempt"`
StartedAt *time.Time `bson:"startedAt,omitempty" json:"startedAt,omitempty"`
CompletedAt *time.Time `bson:"completedAt,omitempty" json:"completedAt,omitempty"`
FailureCode string `bson:"failureCode,omitempty" json:"failureCode,omitempty"`
FailureMsg string `bson:"failureMsg,omitempty" json:"failureMsg,omitempty"`
ExternalRefs []ExternalRef `bson:"externalRefs,omitempty" json:"externalRefs,omitempty"`
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
State StepState `bson:"state" json:"state"`
Attempt uint32 `bson:"attempt" json:"attempt"`
StartedAt *time.Time `bson:"startedAt,omitempty" json:"startedAt,omitempty"`
CompletedAt *time.Time `bson:"completedAt,omitempty" json:"completedAt,omitempty"`
FailureCode string `bson:"failureCode,omitempty" json:"failureCode,omitempty"`
FailureMsg string `bson:"failureMsg,omitempty" json:"failureMsg,omitempty"`
ExternalRefs []ExternalRef `bson:"externalRefs,omitempty" json:"externalRefs,omitempty"`
}
// ExternalRef links step execution to an external operation.

View File

@@ -137,12 +137,19 @@ func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
if stepCode == "" {
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_code is required")
}
visibility := model.NormalizeReportVisibility(shell[i].ReportVisibility)
if !model.IsValidReportVisibility(visibility) {
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].report_visibility is invalid")
}
userLabel := strings.TrimSpace(shell[i].UserLabel)
out = append(out, StepExecution{
StepRef: stepRef,
StepCode: stepCode,
State: StepStatePending,
Attempt: 1,
StepRef: stepRef,
StepCode: stepCode,
ReportVisibility: visibility,
UserLabel: userLabel,
State: StepStatePending,
Attempt: 1,
})
}
return out, nil

View File

@@ -41,8 +41,8 @@ func TestCreate_OK(t *testing.T) {
IntentSnapshot: intent,
QuoteSnapshot: quote,
Steps: []StepShell{
{StepRef: " s1 ", StepCode: " reserve_funds "},
{StepRef: "s2", StepCode: "submit_gateway"},
{StepRef: " s1 ", StepCode: " reserve_funds ", ReportVisibility: model.ReportVisibilityHidden},
{StepRef: "s2", StepCode: "submit_gateway", ReportVisibility: model.ReportVisibilityUser, UserLabel: " Card payout "},
},
})
if err != nil {
@@ -102,6 +102,15 @@ func TestCreate_OK(t *testing.T) {
if payment.StepExecutions[0].State != StepStatePending || payment.StepExecutions[0].Attempt != 1 {
t.Fatalf("unexpected first step shell state: %+v", payment.StepExecutions[0])
}
if got, want := payment.StepExecutions[0].ReportVisibility, model.ReportVisibilityHidden; got != want {
t.Fatalf("unexpected first step visibility: got=%q want=%q", got, want)
}
if got, want := payment.StepExecutions[1].ReportVisibility, model.ReportVisibilityUser; got != want {
t.Fatalf("unexpected second step visibility: got=%q want=%q", got, want)
}
if got, want := payment.StepExecutions[1].UserLabel, "Card payout"; got != want {
t.Fatalf("unexpected second step user label: got=%q want=%q", got, want)
}
// Verify immutable snapshot semantics by ensuring clones were created.
payment.IntentSnapshot.Ref = "changed"
@@ -233,6 +242,19 @@ func TestCreate_InputValidation(t *testing.T) {
},
},
},
{
name: "step report visibility invalid",
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", ReportVisibility: model.ReportVisibility("invalid")},
},
},
},
{
name: "step ref must be unique",
in: Input{

View File

@@ -62,14 +62,14 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo
Money: &paymenttypes.Money{Amount: fee, Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"component": "platform_fee", "provider": "monetix"},
Meta: map[string]string{"component": "platform_fee", "provider": paymenttypes.DefaultCardsGatewayID},
},
{
LedgerAccountRef: "ledger:tax:usdt",
Money: &paymenttypes.Money{Amount: tax, Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeTax,
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"component": "vat", "provider": "monetix"},
Meta: map[string]string{"component": "vat", "provider": paymenttypes.DefaultCardsGatewayID},
},
},
FeeRules: []*paymenttypes.AppliedRule{
@@ -101,7 +101,7 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"},
{Index: 3, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"},
{Index: 3, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: paymenttypes.DefaultCardsGatewayID},
},
Settlement: &paymenttypes.QuoteRouteSettlement{
Model: "fix_source",

View File

@@ -97,6 +97,11 @@ func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
step := src[i]
step.StepRef = strings.TrimSpace(step.StepRef)
step.StepCode = strings.TrimSpace(step.StepCode)
step.ReportVisibility = model.NormalizeReportVisibility(step.ReportVisibility)
if !model.IsValidReportVisibility(step.ReportVisibility) {
step.ReportVisibility = model.ReportVisibilityUnspecified
}
step.UserLabel = strings.TrimSpace(step.UserLabel)
step.FailureCode = strings.TrimSpace(step.FailureCode)
step.FailureMsg = strings.TrimSpace(step.FailureMsg)
if step.Attempt == 0 {

View File

@@ -112,6 +112,15 @@ func TestMap_Success(t *testing.T) {
if got, want := steps[1].GetRefs()[0].GetRail(), gatewayv1.Rail_RAIL_LEDGER; got != want {
t.Fatalf("external ref rail mismatch: got=%s want=%s", got.String(), want.String())
}
if got, want := steps[0].GetReportVisibility(), orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER; got != want {
t.Fatalf("report_visibility mismatch: got=%s want=%s", got.String(), want.String())
}
if got, want := steps[0].GetUserLabel(), "Card payout"; got != want {
t.Fatalf("user_label mismatch: got=%q want=%q", got, want)
}
if got, want := steps[1].GetReportVisibility(), orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN; got != want {
t.Fatalf("report_visibility mismatch: got=%s want=%s", got.String(), want.String())
}
}
func TestMap_InvalidArguments(t *testing.T) {
@@ -348,19 +357,22 @@ func newPaymentFixture() *agg.Payment {
Version: 3,
StepExecutions: []agg.StepExecution{
{
StepRef: "s1",
StepCode: "hop.20.card_payout.send",
State: agg.StepStateRunning,
Attempt: 0,
StartedAt: &startedAt,
StepRef: "s1",
StepCode: "hop.20.card_payout.send",
ReportVisibility: model.ReportVisibilityUser,
UserLabel: " Card payout ",
State: agg.StepStateRunning,
Attempt: 0,
StartedAt: &startedAt,
},
{
StepRef: "s2",
StepCode: "edge.10_20.ledger.debit",
State: agg.StepStateFailed,
Attempt: 2,
FailureCode: "ledger_balance_low",
FailureMsg: "insufficient balance",
StepRef: "s2",
StepCode: "edge.10_20.ledger.debit",
ReportVisibility: model.ReportVisibilityHidden,
State: agg.StepStateFailed,
Attempt: 2,
FailureCode: "ledger_balance_low",
FailureMsg: "insufficient balance",
ExternalRefs: []agg.ExternalRef{
{
GatewayInstanceID: "ledger-1",

View File

@@ -4,6 +4,7 @@ import (
"strings"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/storage/model"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
@@ -81,6 +82,21 @@ func mapStepState(state agg.StepState) orchestrationv2.StepExecutionState {
}
}
func mapReportVisibility(visibility model.ReportVisibility) orchestrationv2.ReportVisibility {
switch model.NormalizeReportVisibility(visibility) {
case model.ReportVisibilityHidden:
return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN
case model.ReportVisibilityUser:
return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER
case model.ReportVisibilityBackoffice:
return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE
case model.ReportVisibilityAudit:
return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT
default:
return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED
}
}
func inferFailureCategory(failureCode string) sharedv1.PaymentFailureCode {
code := strings.ToLower(strings.TrimSpace(failureCode))
switch {

View File

@@ -36,14 +36,16 @@ func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepE
}
return &orchestrationv2.StepExecution{
StepRef: strings.TrimSpace(step.StepRef),
StepCode: strings.TrimSpace(step.StepCode),
State: mapStepState(state),
Attempt: attempt,
StartedAt: tsOrNil(derefTime(step.StartedAt)),
CompletedAt: tsOrNil(derefTime(step.CompletedAt)),
Failure: mapStepFailure(step, state),
Refs: mapExternalRefs(step.StepCode, step.ExternalRefs),
StepRef: strings.TrimSpace(step.StepRef),
StepCode: strings.TrimSpace(step.StepCode),
State: mapStepState(state),
Attempt: attempt,
StartedAt: tsOrNil(derefTime(step.StartedAt)),
CompletedAt: tsOrNil(derefTime(step.CompletedAt)),
Failure: mapStepFailure(step, state),
Refs: mapExternalRefs(step.StepCode, step.ExternalRefs),
ReportVisibility: mapReportVisibility(step.ReportVisibility),
UserLabel: strings.TrimSpace(step.UserLabel),
}, nil
}

View File

@@ -224,8 +224,10 @@ func toStepShells(graph *xplan.Graph) []agg.StepShell {
out := make([]agg.StepShell, 0, len(graph.Steps))
for i := range graph.Steps {
out = append(out, agg.StepShell{
StepRef: graph.Steps[i].StepRef,
StepCode: graph.Steps[i].StepCode,
StepRef: graph.Steps[i].StepRef,
StepCode: graph.Steps[i].StepCode,
ReportVisibility: graph.Steps[i].Visibility,
UserLabel: graph.Steps[i].UserLabel,
})
}
return out

View File

@@ -603,21 +603,26 @@ func newExecutableQuote(orgRef bson.ObjectID, quoteRef, intentRef string, route
OrganizationBoundBase: pm.OrganizationBoundBase{
OrganizationRef: orgRef,
},
QuoteRef: quoteRef,
Intent: model.PaymentIntent{
Ref: intentRef,
Kind: model.PaymentKindPayout,
Source: testLedgerEndpoint("ledger-src"),
Destination: testLedgerEndpoint("ledger-dst"),
Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
SettlementCurrency: "USD",
QuoteRef: quoteRef,
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{
Ref: intentRef,
Kind: model.PaymentKindPayout,
Source: testLedgerEndpoint("ledger-src"),
Destination: testLedgerEndpoint("ledger-dst"),
Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
SettlementCurrency: "USD",
},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: quoteRef,
DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
Route: route,
},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: quoteRef,
DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
Route: route,
},
StatusV2: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
ExpiresAt: now.Add(1 * time.Hour),
}
}

View File

@@ -35,13 +35,14 @@ func TestResolve_Expired(t *testing.T) {
_, 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,
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(-time.Second),
}, nil
@@ -62,14 +63,17 @@ func TestResolve_NotExecutableState(t *testing.T) {
_, 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,
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{
State: model.QuoteStateBlocked,
BlockReason: model.QuoteBlockReasonRouteUnavailable,
},
},
},
ExpiresAt: now.Add(time.Minute),
}, nil
@@ -83,20 +87,23 @@ func TestResolve_NotExecutableState(t *testing.T) {
}
}
func TestResolve_NotExecutableExecutionNote(t *testing.T) {
func TestResolve_NotExecutableIndicative(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := New(Dependencies{Logger: zap.NewNop(), 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,
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateIndicative},
},
},
Quote: &model.PaymentQuoteSnapshot{},
ExecutionNote: "quote will not be executed",
ExpiresAt: now.Add(time.Minute),
ExpiresAt: now.Add(time.Minute),
}, nil
},
}, Input{
@@ -115,13 +122,19 @@ func TestResolve_ShapeMismatch(t *testing.T) {
_, 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{
{},
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(time.Minute),
}, nil

View File

@@ -17,18 +17,23 @@ func TestResolve_SingleShapeOK(t *testing.T) {
orgID := bson.NewObjectID()
record := &model.PaymentQuoteRecord{
QuoteRef: "stored-quote-ref",
Intent: model.PaymentIntent{
Ref: "intent-1",
Kind: model.PaymentKindPayout,
QuoteRef: "stored-quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
ExpiresAt: now.Add(time.Minute),
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{
Ref: "intent-1",
Kind: model.PaymentKindPayout,
},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: "",
},
Status: &model.QuoteStatusV2{
State: model.QuoteStateExecutable,
},
},
},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: "",
},
StatusV2: &model.QuoteStatusV2{
State: model.QuoteStateExecutable,
},
ExpiresAt: now.Add(time.Minute),
}
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
@@ -65,8 +70,8 @@ func TestResolve_SingleShapeOK(t *testing.T) {
}
out.QuoteSnapshot.QuoteRef = "changed"
if record.Quote.QuoteRef != "" {
t.Fatalf("expected stored quote snapshot to be unchanged, got %q", record.Quote.QuoteRef)
if record.Items[0].Quote.QuoteRef != "" {
t.Fatalf("expected stored quote snapshot to be unchanged, got %q", record.Items[0].Quote.QuoteRef)
}
}
@@ -75,15 +80,14 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
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},
QuoteRef: "batch-like-single",
RequestShape: model.QuoteRequestShapeBatch,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindInternalTransfer},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "snapshot-ref"},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(time.Minute),
}
@@ -123,18 +127,19 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) {
orgID := bson.NewObjectID()
record := &model.PaymentQuoteRecord{
QuoteRef: "batch-quote-ref",
Intents: []model.PaymentIntent{
{Ref: "intent-a", Kind: model.PaymentKindPayout},
{Ref: "intent-b", Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}},
{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "15", Currency: "USDT"}},
},
StatusesV2: []*model.QuoteStatusV2{
{State: model.QuoteStateExecutable},
{State: model.QuoteStateExecutable},
QuoteRef: "batch-quote-ref",
RequestShape: model.QuoteRequestShapeBatch,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Ref: "intent-a", Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
{
Intent: &model.PaymentIntent{Ref: "intent-b", Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "15", Currency: "USDT"}},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(time.Minute),
}
@@ -177,18 +182,19 @@ func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) {
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intents: []model.PaymentIntent{
{Ref: "intent-1", Kind: model.PaymentKindPayout},
{Ref: "intent-2", Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{},
{},
},
StatusesV2: []*model.QuoteStatusV2{
{State: model.QuoteStateExecutable},
{State: model.QuoteStateExecutable},
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeBatch,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
{
Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(time.Minute),
}, nil
@@ -209,18 +215,19 @@ func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) {
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intents: []model.PaymentIntent{
{Ref: "intent-1", Kind: model.PaymentKindPayout},
{Ref: "intent-2", Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{},
{},
},
StatusesV2: []*model.QuoteStatusV2{
{State: model.QuoteStateExecutable},
{State: model.QuoteStateExecutable},
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeBatch,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
{
Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(time.Minute),
}, nil

View File

@@ -114,13 +114,8 @@ func ensureExecutable(
return ErrQuoteExpired
}
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
return xerr.Wrapf(ErrQuoteNotExecutable, "%s", note)
}
if status == nil {
// Legacy records may not have status metadata.
return nil
return xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil")
}
switch status.State {
@@ -150,36 +145,75 @@ func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*res
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil")
}
hasArrayShape := len(record.Intents) > 0 || len(record.Quotes) > 0 || len(record.StatusesV2) > 0
if hasArrayShape {
return resolveArrayShapeItem(record, intentRef)
if len(record.Items) == 0 {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "items are empty")
}
switch record.RequestShape {
case model.QuoteRequestShapeSingle:
if len(record.Items) != 1 {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "single shape requires exactly one item")
}
return resolveItem(record.Items[0], intentRef)
case model.QuoteRequestShapeBatch:
index, err := resolveBatchItemIndex(record.Items, intentRef)
if err != nil {
return nil, err
}
return resolveItem(record.Items[index], intentRef)
default:
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "request shape is invalid")
}
return resolveSingleShapeItem(record, intentRef)
}
func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
if record == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil")
}
if record.Quote == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is empty")
}
if isEmptyIntentSnapshot(record.Intent) {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intent snapshot is empty")
}
if intentRef != "" {
recordIntentRef := strings.TrimSpace(record.Intent.Ref)
if recordIntentRef == "" || recordIntentRef != intentRef {
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
func resolveBatchItemIndex(items []*model.PaymentQuoteItemV2, intentRef string) (int, error) {
if len(items) == 1 {
if intentRef == "" {
return 0, nil
}
item := items[0]
if item == nil || item.Intent == nil || strings.TrimSpace(item.Intent.Ref) != intentRef {
return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
}
return 0, nil
}
intentSnapshot, err := cloneIntentSnapshot(record.Intent)
if intentRef == "" {
return -1, ErrIntentRefRequired
}
index, found := findItemIndex(items, intentRef)
if !found {
return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
}
return index, nil
}
func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuoteItem, error) {
if item == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "item is nil")
}
if item.Intent == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intent snapshot is nil")
}
if item.Quote == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is nil")
}
if item.Status == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil")
}
if intentRef != "" && strings.TrimSpace(item.Intent.Ref) != intentRef {
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
}
intentSnapshot, err := cloneIntentSnapshot(*item.Intent)
if err != nil {
return nil, err
}
quoteSnapshot, err := cloneQuoteSnapshot(record.Quote)
quoteSnapshot, err := cloneQuoteSnapshot(item.Quote)
if err != nil {
return nil, err
}
statusSnapshot, err := cloneStatusSnapshot(item.Status)
if err != nil {
return nil, err
}
@@ -187,76 +221,21 @@ func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string)
return &resolvedQuoteItem{
Intent: intentSnapshot,
Quote: quoteSnapshot,
Status: record.StatusV2,
}, nil
}
func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
if len(record.Intents) == 0 {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents are empty")
}
if len(record.Quotes) == 0 {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quotes are empty")
}
if len(record.Intents) != len(record.Quotes) {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents and quotes count mismatch")
}
if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "statuses and quotes count mismatch")
}
index := 0
if len(record.Intents) > 1 {
if intentRef == "" {
return nil, ErrIntentRefRequired
}
selected, found := findIntentIndex(record.Intents, intentRef)
if !found {
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
}
index = selected
} else if intentRef != "" {
if strings.TrimSpace(record.Intents[0].Ref) != intentRef {
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
}
}
quoteSnapshot := record.Quotes[index]
if quoteSnapshot == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is nil")
}
intentSnapshot, err := cloneIntentSnapshot(record.Intents[index])
if err != nil {
return nil, err
}
clonedQuote, err := cloneQuoteSnapshot(quoteSnapshot)
if err != nil {
return nil, err
}
var statusSnapshot *model.QuoteStatusV2
if len(record.StatusesV2) > 0 {
if record.StatusesV2[index] == nil {
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil")
}
statusSnapshot = record.StatusesV2[index]
}
return &resolvedQuoteItem{
Intent: intentSnapshot,
Quote: clonedQuote,
Status: statusSnapshot,
}, nil
}
func findIntentIndex(intents []model.PaymentIntent, targetRef string) (int, bool) {
func findItemIndex(items []*model.PaymentQuoteItemV2, targetRef string) (int, bool) {
target := strings.TrimSpace(targetRef)
if target == "" {
return -1, false
}
for idx := range intents {
if strings.TrimSpace(intents[idx].Ref) == target {
for idx := range items {
item := items[idx]
if item == nil || item.Intent == nil {
continue
}
if strings.TrimSpace(item.Intent.Ref) == target {
return idx, true
}
}
@@ -291,6 +270,17 @@ func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSna
return dst, nil
}
func cloneStatusSnapshot(src *model.QuoteStatusV2) (*model.QuoteStatusV2, error) {
if src == nil {
return nil, nil
}
dst := &model.QuoteStatusV2{}
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 {
@@ -298,7 +288,3 @@ func bsonClone(src any, dst any) error {
}
return bson.Unmarshal(data, dst)
}
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
@@ -143,6 +144,8 @@ func (s *svc) normalizeStepExecutions(
stepCode = stepsByRef[stepRef].StepCode
}
exec.StepCode = stepCode
exec.ReportVisibility = effectiveStepVisibility(exec.ReportVisibility, stepsByRef[stepRef].Visibility)
exec.UserLabel = firstNonEmpty(exec.UserLabel, stepsByRef[stepRef].UserLabel)
cloned := cloneStepExecution(exec)
out[stepRef] = &cloned
}
@@ -154,10 +157,15 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste
exec.StepCode = strings.TrimSpace(exec.StepCode)
exec.FailureCode = strings.TrimSpace(exec.FailureCode)
exec.FailureMsg = strings.TrimSpace(exec.FailureMsg)
exec.UserLabel = strings.TrimSpace(exec.UserLabel)
exec.ReportVisibility = model.NormalizeReportVisibility(exec.ReportVisibility)
exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
if exec.StepRef == "" {
return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].step_ref is required")
}
if !model.IsValidReportVisibility(exec.ReportVisibility) {
return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].report_visibility is invalid")
}
state, ok := normalizeStepState(exec.State)
if !ok {
@@ -187,14 +195,25 @@ func seedMissingExecutions(
maxAttemptsByRef[stepRef] = 1
}
executionsByRef[stepRef] = &agg.StepExecution{
StepRef: step.StepRef,
StepCode: step.StepCode,
State: agg.StepStatePending,
Attempt: attempt,
StepRef: step.StepRef,
StepCode: step.StepCode,
ReportVisibility: effectiveStepVisibility(model.ReportVisibilityUnspecified, step.Visibility),
UserLabel: strings.TrimSpace(step.UserLabel),
State: agg.StepStatePending,
Attempt: attempt,
}
}
}
func effectiveStepVisibility(execVisibility, graphVisibility model.ReportVisibility) model.ReportVisibility {
execVisibility = model.NormalizeReportVisibility(execVisibility)
graphVisibility = model.NormalizeReportVisibility(graphVisibility)
if execVisibility != model.ReportVisibilityUnspecified {
return execVisibility
}
return graphVisibility
}
func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
switch strings.ToLower(strings.TrimSpace(string(state))) {
case "":

View File

@@ -120,6 +120,84 @@ func TestSchedule_AfterFailureRunsWhenDependencyExhausted(t *testing.T) {
assertBlockedReason(t, out, "observe", BlockedNeedsAttention)
}
func TestSchedule_SendFailureRunsSendFailureReleaseAndSkipsObserveBranches(t *testing.T) {
runtime := New()
out, err := runtime.Schedule(Input{
Steps: []xplan.Step{
step("send", nil),
step("observe", []string{"send"}),
successStep("debit", "observe"),
failureStep("release_send", "send"),
failureStep("release_observe", "observe"),
},
StepExecutions: []agg.StepExecution{
exec("send", agg.StepStateFailed, 2),
exec("observe", agg.StepStatePending, 1),
exec("debit", agg.StepStatePending, 1),
exec("release_send", agg.StepStatePending, 1),
exec("release_observe", agg.StepStatePending, 1),
},
Retry: RetryPolicy{MaxAttempts: 2},
})
if err != nil {
t.Fatalf("Schedule returned error: %v", err)
}
send := mustExecution(t, out, "send")
if send.State != agg.StepStateNeedsAttention {
t.Fatalf("send state mismatch: got=%q want=%q", send.State, agg.StepStateNeedsAttention)
}
assertRunnableRefs(t, out, []string{"release_send"})
assertSkippedRefs(t, out, []string{"observe", "debit", "release_observe"})
assertBlockedReason(t, out, "send", BlockedNeedsAttention)
releaseSend := mustExecution(t, out, "release_send")
if releaseSend.State != agg.StepStatePending {
t.Fatalf("release_send state mismatch: got=%q want=%q", releaseSend.State, agg.StepStatePending)
}
}
func TestSchedule_ObserveFailureRunsObserveFailureReleaseAndSkipsSendFailureRelease(t *testing.T) {
runtime := New()
out, err := runtime.Schedule(Input{
Steps: []xplan.Step{
step("send", nil),
step("observe", []string{"send"}),
successStep("debit", "observe"),
failureStep("release_send", "send"),
failureStep("release_observe", "observe"),
},
StepExecutions: []agg.StepExecution{
exec("send", agg.StepStateCompleted, 1),
exec("observe", agg.StepStateFailed, 2),
exec("debit", agg.StepStatePending, 1),
exec("release_send", agg.StepStatePending, 1),
exec("release_observe", agg.StepStatePending, 1),
},
Retry: RetryPolicy{MaxAttempts: 2},
})
if err != nil {
t.Fatalf("Schedule returned error: %v", err)
}
observe := mustExecution(t, out, "observe")
if observe.State != agg.StepStateNeedsAttention {
t.Fatalf("observe state mismatch: got=%q want=%q", observe.State, agg.StepStateNeedsAttention)
}
assertRunnableRefs(t, out, []string{"release_observe"})
assertSkippedRefs(t, out, []string{"debit", "release_send"})
assertBlockedReason(t, out, "observe", BlockedNeedsAttention)
releaseObserve := mustExecution(t, out, "release_observe")
if releaseObserve.State != agg.StepStatePending {
t.Fatalf("release_observe state mismatch: got=%q want=%q", releaseObserve.State, agg.StepStatePending)
}
}
func TestSchedule_RetryExhaustedPromotesNeedsAttention(t *testing.T) {
runtime := New()

View File

@@ -43,8 +43,8 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
if got, want := graph.RouteRef, "route-1"; got != want {
t.Fatalf("route_ref mismatch: got=%q want=%q", got, want)
}
if len(graph.Steps) != 8 {
t.Fatalf("expected 8 steps, got %d", len(graph.Steps))
if len(graph.Steps) != 9 {
t.Fatalf("expected 9 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice)
@@ -55,6 +55,7 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
assertStep(t, graph.Steps[5], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[6], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[7], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[8], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("step[1] deps mismatch: got=%v want=%v", got, want)
@@ -76,19 +77,28 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
t.Fatalf("expected debit commit policy AFTER_SUCCESS, got %q", graph.Steps[6].CommitPolicy)
}
if graph.Steps[7].CommitPolicy != model.CommitPolicyAfterFailure {
t.Fatalf("expected release commit policy AFTER_FAILURE, got %q", graph.Steps[7].CommitPolicy)
t.Fatalf("expected send-failure release commit policy AFTER_FAILURE, got %q", graph.Steps[7].CommitPolicy)
}
if graph.Steps[8].CommitPolicy != model.CommitPolicyAfterFailure {
t.Fatalf("expected observe-failure release commit policy AFTER_FAILURE, got %q", graph.Steps[8].CommitPolicy)
}
if got, want := graph.Steps[6].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("debit commit_after mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[7].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("release commit_after mismatch: got=%v want=%v", got, want)
if got, want := graph.Steps[7].CommitAfter, []string{graph.Steps[4].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("send-failure release commit_after mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[8].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("observe-failure release commit_after mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[6].Metadata["mode"], "finalize_debit"; got != want {
t.Fatalf("expected debit mode %q, got %q", want, got)
}
if got, want := graph.Steps[7].Metadata["mode"], "unlock_hold"; got != want {
t.Fatalf("expected release mode %q, got %q", want, got)
t.Fatalf("expected send-failure release mode %q, got %q", want, got)
}
if got, want := graph.Steps[8].Metadata["mode"], "unlock_hold"; got != want {
t.Fatalf("expected observe-failure release mode %q, got %q", want, got)
}
}
@@ -109,14 +119,21 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T)
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 5 {
t.Fatalf("expected 5 steps, got %d", len(graph.Steps))
if len(graph.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[1], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[2], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[3], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[4], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[5], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
if got, want := graph.Steps[4].CommitAfter, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("send-failure release commit_after mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[5].CommitAfter, []string{graph.Steps[2].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("observe-failure release commit_after mismatch: got=%v want=%v", got, want)
}
}
func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) {
@@ -246,8 +263,8 @@ func TestCompile_GuardsArePrepended(t *testing.T) {
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 7 {
t.Fatalf("expected 7 steps, got %d", len(graph.Steps))
if len(graph.Steps) != 8 {
t.Fatalf("expected 8 steps, got %d", len(graph.Steps))
}
if graph.Steps[0].Kind != StepKindLiquidityCheck {
t.Fatalf("expected first guard liquidity_check, got %q", graph.Steps[0].Kind)

View File

@@ -237,6 +237,21 @@ func appendSettlementBranches(
if strings.TrimSpace(anchorObserveRef) == "" {
return
}
anchorSendRef := strings.TrimSpace(anchorObserveRef)
if anchorSendRef != "" {
for i := range ex.steps {
step := ex.steps[i]
if strings.TrimSpace(step.StepRef) != anchorObserveRef {
continue
}
if len(step.DependsOn) > 0 {
anchorSendRef = strings.TrimSpace(step.DependsOn[0])
} else {
anchorSendRef = ""
}
break
}
}
successStep := Step{
StepCode: edgeCode(from, to, rail, "debit"),
@@ -253,6 +268,23 @@ func appendSettlementBranches(
}
ex.appendBranch(successStep)
if anchorSendRef != "" {
sendFailureStep := Step{
StepCode: edgeCode(from, to, rail, "release"),
Kind: StepKindFundsRelease,
Action: model.RailOperationRelease,
DependsOn: []string{anchorSendRef},
Rail: rail,
HopIndex: to.index,
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
Visibility: model.ReportVisibilityHidden,
CommitPolicy: model.CommitPolicyAfterFailure,
CommitAfter: []string{anchorSendRef},
Metadata: map[string]string{"mode": "unlock_hold"},
}
ex.appendBranch(sendFailureStep)
}
failureStep := Step{
StepCode: edgeCode(from, to, rail, "release"),
Kind: StepKindFundsRelease,

View File

@@ -79,8 +79,8 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
StepCode: "hop.4.card_payout.send",
Action: model.RailOperationSend,
Rail: model.RailCardPayout,
Gateway: "monetix",
InstanceID: "monetix",
Gateway: paymenttypes.DefaultCardsGatewayID,
InstanceID: paymenttypes.DefaultCardsGatewayID,
},
StepExecution: agg.StepExecution{
StepRef: "hop_4_card_payout_send",

View File

@@ -47,7 +47,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) {
gatewayInvokeResolver: resolver,
gatewayRegistry: registry,
cardGatewayRoutes: map[string]CardGatewayRoute{
"monetix": {FundingAddress: "TUA_DEST"},
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST"},
},
}
@@ -75,7 +75,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) {
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 4, Rail: "CARD", Gateway: "monetix", InstanceID: "monetix", Role: paymenttypes.QuoteRouteHopRoleDestination},
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
@@ -170,7 +170,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) {
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_1", InstanceID: "crypto_1", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 4, Rail: "CARD", Gateway: "monetix", InstanceID: "monetix", Role: paymenttypes.QuoteRouteHopRoleDestination},
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},

View File

@@ -108,7 +108,7 @@ func TestBuildGatewayExecutionEvent_MatchesCardObserveByCardPayoutRef(t *testing
State: agg.StepStateRunning,
ExternalRefs: []agg.ExternalRef{
{
GatewayInstanceID: "monetix",
GatewayInstanceID: paymenttypes.DefaultCardsGatewayID,
Kind: erecon.ExternalRefKindCardPayout,
Ref: "payout-1",
},
@@ -128,7 +128,7 @@ func TestBuildGatewayExecutionEvent_MatchesCardObserveByCardPayoutRef(t *testing
if got, want := event.StepRef, "hop_4_card_payout_observe"; got != want {
t.Fatalf("step_ref mismatch: got=%q want=%q", got, want)
}
if got, want := event.GatewayInstanceID, "monetix"; got != want {
if got, want := event.GatewayInstanceID, paymenttypes.DefaultCardsGatewayID; got != want {
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
}
}
@@ -319,7 +319,7 @@ func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) {
State: agg.StepStateRunning,
ExternalRefs: []agg.ExternalRef{
{
GatewayInstanceID: "monetix",
GatewayInstanceID: paymenttypes.DefaultCardsGatewayID,
Kind: erecon.ExternalRefKindCardPayout,
Ref: "payout-2",
},
@@ -335,7 +335,7 @@ func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) {
if got, want := candidates[0].transferRef, "payout-2"; got != want {
t.Fatalf("transfer_ref mismatch: got=%q want=%q", got, want)
}
if got, want := candidates[0].gatewayInstanceID, "monetix"; got != want {
if got, want := candidates[0].gatewayInstanceID, paymenttypes.DefaultCardsGatewayID; got != want {
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -228,8 +228,8 @@ func testLiquidityProbePayment(
{
Index: 2,
Rail: "CARD",
Gateway: "monetix",
InstanceID: "monetix",
Gateway: paymenttypes.DefaultCardsGatewayID,
InstanceID: paymenttypes.DefaultCardsGatewayID,
Role: paymenttypes.QuoteRouteHopRoleDestination,
},
},

View File

@@ -138,6 +138,108 @@ func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSe
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_BlockUsesOperatingToHoldAndPayoutAmount(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)
var transferReq *ledgerv1.TransferRequest
executor := &gatewayLedgerExecutor{
ledgerClient: &ledgerclient.Fake{
TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
transferReq = req
return &ledgerv1.PostResponse{JournalEntryRef: "entry-block"}, nil
},
},
}
out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "edge_3_4_ledger_block",
StepCode: "edge.3_4.ledger.block",
Action: model.RailOperationBlock,
Rail: model.RailLedger,
},
StepExecution: agg.StepExecution{
StepRef: "edge_3_4_ledger_block",
StepCode: "edge.3_4.ledger.block",
Attempt: 1,
},
})
if err != nil {
t.Fatalf("ExecuteLedger returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if transferReq == nil {
t.Fatal("expected ledger transfer request")
}
if got, want := transferReq.GetMoney().GetAmount(), "76.5"; got != want {
t.Fatalf("money.amount mismatch: got=%q want=%q", got, want)
}
if got, want := transferReq.GetMoney().GetCurrency(), "RUB"; got != want {
t.Fatalf("money.currency mismatch: got=%q want=%q", got, want)
}
if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want {
t.Fatalf("from_role mismatch: got=%v want=%v", got, want)
}
if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want {
t.Fatalf("to_role mismatch: got=%v want=%v", got, want)
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_ReleaseUsesHoldToOperatingAndPayoutAmount(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)
var transferReq *ledgerv1.TransferRequest
executor := &gatewayLedgerExecutor{
ledgerClient: &ledgerclient.Fake{
TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
transferReq = req
return &ledgerv1.PostResponse{JournalEntryRef: "entry-release"}, nil
},
},
}
out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "edge_3_4_ledger_release",
StepCode: "edge.3_4.ledger.release",
Action: model.RailOperationRelease,
Rail: model.RailLedger,
},
StepExecution: agg.StepExecution{
StepRef: "edge_3_4_ledger_release",
StepCode: "edge.3_4.ledger.release",
Attempt: 1,
},
})
if err != nil {
t.Fatalf("ExecuteLedger returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if transferReq == nil {
t.Fatal("expected ledger transfer request")
}
if got, want := transferReq.GetMoney().GetAmount(), "76.5"; got != want {
t.Fatalf("money.amount mismatch: got=%q want=%q", got, want)
}
if got, want := transferReq.GetMoney().GetCurrency(), "RUB"; got != want {
t.Fatalf("money.currency mismatch: got=%q want=%q", got, want)
}
if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want {
t.Fatalf("from_role mismatch: got=%v want=%v", got, want)
}
if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want {
t.Fatalf("to_role mismatch: got=%v want=%v", got, want)
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_UsesMetadataRoleOverrides(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)