+source currency pick fix +fx side propagation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 "":
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,8 +228,8 @@ func testLiquidityProbePayment(
|
||||
{
|
||||
Index: 2,
|
||||
Rail: "CARD",
|
||||
Gateway: "monetix",
|
||||
InstanceID: "monetix",
|
||||
Gateway: paymenttypes.DefaultCardsGatewayID,
|
||||
InstanceID: paymenttypes.DefaultCardsGatewayID,
|
||||
Role: paymenttypes.QuoteRouteHopRoleDestination,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user