+source currency pick fix +fx side propagation
This commit is contained in:
@@ -42,11 +42,11 @@ max_fx_quote_ttl_ms: 600000
|
||||
|
||||
# Service endpoints are sourced from discovery; no static overrides.
|
||||
card_gateways:
|
||||
monetix:
|
||||
mcards:
|
||||
funding_address: "TUaWaCkiXwYPKm5qjcB27Lhwv976vPvedE"
|
||||
fee_wallet_ref: "697a062a248dc785125ccb9e"
|
||||
|
||||
fee_ledger_accounts:
|
||||
monetix: "697a15cc72e95c92d4c5db01"
|
||||
mcards: "697a15cc72e95c92d4c5db01"
|
||||
|
||||
# Gateway instances and capabilities are sourced from service discovery.
|
||||
|
||||
@@ -42,11 +42,11 @@ max_fx_quote_ttl_ms: 600000
|
||||
|
||||
# Service endpoints are sourced from discovery; no static overrides.
|
||||
card_gateways:
|
||||
monetix:
|
||||
mcards:
|
||||
funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||
fee_wallet_ref: "694c124ed76f9f811ac57133"
|
||||
|
||||
fee_ledger_accounts:
|
||||
monetix: "ledger:fees:monetix"
|
||||
mcards: "ledger:fees:monetix"
|
||||
|
||||
# Gateway instances and capabilities are sourced from service discovery.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package quotation
|
||||
|
||||
import paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
|
||||
const (
|
||||
defaultCardGateway = "monetix"
|
||||
defaultCardGateway = paymenttypes.DefaultCardsGatewayID
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCardGatewayKey = "monetix"
|
||||
defaultCardGatewayKey = paymenttypes.DefaultCardsGatewayID
|
||||
)
|
||||
|
||||
type CardGatewayFundingRoute struct {
|
||||
|
||||
@@ -14,13 +14,13 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) {
|
||||
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
|
||||
DefaultMode: model.FundingModeNone,
|
||||
CardRoutes: map[string]CardGatewayFundingRoute{
|
||||
"monetix": {
|
||||
paymenttypes.DefaultCardsGatewayID: {
|
||||
FundingAddress: "T-FUNDING",
|
||||
FeeWalletRef: "wallet-fee",
|
||||
},
|
||||
},
|
||||
FeeLedgerAccounts: map[string]string{
|
||||
"monetix": "ledger:fees",
|
||||
paymenttypes.DefaultCardsGatewayID: "ledger:fees",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
"gateway": "monetix",
|
||||
"gateway": paymenttypes.DefaultCardsGatewayID,
|
||||
"initiator_ref": "usr-1",
|
||||
},
|
||||
})
|
||||
@@ -57,8 +57,8 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) {
|
||||
if profile == nil {
|
||||
t.Fatalf("expected profile")
|
||||
}
|
||||
if profile.GatewayID != "monetix" {
|
||||
t.Fatalf("expected gateway monetix, got %q", profile.GatewayID)
|
||||
if profile.GatewayID != paymenttypes.DefaultCardsGatewayID {
|
||||
t.Fatalf("expected gateway %s, got %q", paymenttypes.DefaultCardsGatewayID, profile.GatewayID)
|
||||
}
|
||||
if profile.Mode != model.FundingModeNone {
|
||||
t.Fatalf("expected mode none, got %q", profile.Mode)
|
||||
@@ -149,7 +149,7 @@ func TestStaticFundingProfileResolver_EmptyInputReturnsNil(t *testing.T) {
|
||||
func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) {
|
||||
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
|
||||
Profiles: map[string]*GatewayFundingProfile{
|
||||
"monetix": {
|
||||
paymenttypes.DefaultCardsGatewayID: {
|
||||
Mode: model.FundingModeDepositObserved,
|
||||
DepositCheck: &model.DepositCheckPolicy{
|
||||
WalletRef: "wallet-deposit",
|
||||
@@ -164,7 +164,7 @@ func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) {
|
||||
})
|
||||
|
||||
first, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
|
||||
GatewayID: "monetix",
|
||||
GatewayID: paymenttypes.DefaultCardsGatewayID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -175,7 +175,7 @@ func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) {
|
||||
first.DepositCheck.WalletRef = "changed"
|
||||
|
||||
second, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
|
||||
GatewayID: "monetix",
|
||||
GatewayID: paymenttypes.DefaultCardsGatewayID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -67,7 +67,21 @@ func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent {
|
||||
return nil
|
||||
}
|
||||
if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil {
|
||||
return fx
|
||||
side := fx.GetSide()
|
||||
if side == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
side = fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
}
|
||||
return &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: strings.TrimSpace(fx.GetPair().GetBase()),
|
||||
Quote: strings.TrimSpace(fx.GetPair().GetQuote()),
|
||||
},
|
||||
Side: side,
|
||||
Firm: fx.GetFirm(),
|
||||
TtlMs: fx.GetTtlMs(),
|
||||
PreferredProvider: strings.TrimSpace(fx.GetPreferredProvider()),
|
||||
MaxAgeMs: fx.GetMaxAgeMs(),
|
||||
}
|
||||
}
|
||||
amount := intent.GetAmount()
|
||||
if amount == nil {
|
||||
|
||||
@@ -114,3 +114,30 @@ func TestShouldRequestFX_UsesFXIntentOrCurrencyDifference(t *testing.T) {
|
||||
t.Fatalf("expected shouldRequestFX=true for derived FX from currency mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFXIntentForQuote_DefaultsUnspecifiedSideForExplicitPair(t *testing.T) {
|
||||
intent := &sharedv1.PaymentIntent{
|
||||
Fx: &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||
Side: fxv1.Side_SIDE_UNSPECIFIED,
|
||||
Firm: true,
|
||||
TtlMs: 5000,
|
||||
PreferredProvider: "provider-a",
|
||||
MaxAgeMs: 750,
|
||||
},
|
||||
}
|
||||
|
||||
fx := fxIntentForQuote(intent)
|
||||
if fx == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if fx.GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
|
||||
t.Fatalf("unexpected side: got=%s", fx.GetSide().String())
|
||||
}
|
||||
if fx.GetPair() == nil || fx.GetPair().GetBase() != "USDT" || fx.GetPair().GetQuote() != "RUB" {
|
||||
t.Fatalf("unexpected pair: %+v", fx.GetPair())
|
||||
}
|
||||
if !fx.GetFirm() || fx.GetTtlMs() != 5000 || fx.GetPreferredProvider() != "provider-a" || fx.GetMaxAgeMs() != 750 {
|
||||
t.Fatalf("unexpected fx options: %+v", fx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainpkg "github.com/tech/sendico/pkg/chain"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -22,6 +23,13 @@ type managedWalletNetworkResolver struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type discoveredGatewayCandidate struct {
|
||||
gatewayID string
|
||||
instanceID string
|
||||
network string
|
||||
invokeURI string
|
||||
}
|
||||
|
||||
func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolver {
|
||||
if core == nil {
|
||||
return nil
|
||||
@@ -39,23 +47,35 @@ func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolve
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) {
|
||||
asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
network := strings.ToUpper(strings.TrimSpace(asset.GetChain()))
|
||||
if network == "" {
|
||||
return "", merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) ResolveManagedWalletAsset(ctx context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
|
||||
if r == nil {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
walletRef := strings.TrimSpace(managedWalletRef)
|
||||
if walletRef == "" {
|
||||
return "", merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
return nil, merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
}
|
||||
|
||||
var discoveryErr error
|
||||
if r.gatewayRegistry != nil && r.gatewayInvokeResolver != nil {
|
||||
network, err := r.resolveFromDiscoveredGateways(ctx, walletRef)
|
||||
asset, err := r.resolveAssetFromDiscoveredGateways(ctx, walletRef)
|
||||
if err == nil {
|
||||
return network, nil
|
||||
return asset, nil
|
||||
}
|
||||
discoveryErr = err
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Managed wallet network lookup via discovery failed",
|
||||
r.logger.Warn("Managed wallet asset lookup via discovery failed",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -64,72 +84,33 @@ func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.C
|
||||
|
||||
if r.resolver == nil {
|
||||
if discoveryErr != nil {
|
||||
return "", discoveryErr
|
||||
return nil, discoveryErr
|
||||
}
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
|
||||
client, err := r.resolver.Resolve(ctx, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if client == nil {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
resp, err := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return managedWalletNetworkFromResponse(resp)
|
||||
return managedWalletAssetFromResponse(resp)
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context.Context, walletRef string) (string, error) {
|
||||
entries, err := r.gatewayRegistry.List(ctx)
|
||||
func (r *managedWalletNetworkResolver) resolveAssetFromDiscoveredGateways(ctx context.Context, walletRef string) (*paymenttypes.Asset, error) {
|
||||
candidates, err := r.listDiscoveredGatewayCandidates(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
gatewayID string
|
||||
instanceID string
|
||||
network string
|
||||
invokeURI string
|
||||
}
|
||||
candidates := make([]candidate, 0, len(entries))
|
||||
seenInvokeURI := map[string]struct{}{}
|
||||
for _, entry := range entries {
|
||||
if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto {
|
||||
continue
|
||||
}
|
||||
invokeURI := strings.TrimSpace(entry.InvokeURI)
|
||||
if invokeURI == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(invokeURI)
|
||||
if _, exists := seenInvokeURI[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenInvokeURI[key] = struct{}{}
|
||||
candidates = append(candidates, candidate{
|
||||
gatewayID: strings.TrimSpace(entry.ID),
|
||||
instanceID: strings.TrimSpace(entry.InstanceID),
|
||||
network: strings.ToUpper(strings.TrimSpace(entry.Network)),
|
||||
invokeURI: invokeURI,
|
||||
})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].gatewayID != candidates[j].gatewayID {
|
||||
return candidates[i].gatewayID < candidates[j].gatewayID
|
||||
}
|
||||
if candidates[i].instanceID != candidates[j].instanceID {
|
||||
return candidates[i].instanceID < candidates[j].instanceID
|
||||
}
|
||||
return candidates[i].invokeURI < candidates[j].invokeURI
|
||||
})
|
||||
|
||||
var firstErr error
|
||||
for _, candidate := range candidates {
|
||||
@@ -150,7 +131,7 @@ func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context
|
||||
}
|
||||
continue
|
||||
}
|
||||
network, extractErr := managedWalletNetworkFromResponse(resp)
|
||||
asset, extractErr := managedWalletAssetFromResponse(resp)
|
||||
if extractErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = extractErr
|
||||
@@ -163,30 +144,87 @@ func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context
|
||||
zap.String("gateway_id", candidate.gatewayID),
|
||||
zap.String("instance_id", candidate.instanceID),
|
||||
zap.String("gateway_network", candidate.network),
|
||||
zap.String("resolved_network", network),
|
||||
zap.String("resolved_network", asset.GetChain()),
|
||||
)
|
||||
}
|
||||
return network, nil
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
if firstErr != nil {
|
||||
return "", firstErr
|
||||
return nil, firstErr
|
||||
}
|
||||
return "", merrors.NoData("managed wallet not found in discovered gateways")
|
||||
return nil, merrors.NoData("managed wallet not found in discovered gateways")
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) listDiscoveredGatewayCandidates(ctx context.Context) ([]discoveredGatewayCandidate, error) {
|
||||
entries, err := r.gatewayRegistry.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
candidates := make([]discoveredGatewayCandidate, 0, len(entries))
|
||||
seenInvokeURI := map[string]struct{}{}
|
||||
for _, entry := range entries {
|
||||
if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto {
|
||||
continue
|
||||
}
|
||||
invokeURI := strings.TrimSpace(entry.InvokeURI)
|
||||
if invokeURI == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(invokeURI)
|
||||
if _, exists := seenInvokeURI[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenInvokeURI[key] = struct{}{}
|
||||
candidates = append(candidates, discoveredGatewayCandidate{
|
||||
gatewayID: strings.TrimSpace(entry.ID),
|
||||
instanceID: strings.TrimSpace(entry.InstanceID),
|
||||
network: strings.ToUpper(strings.TrimSpace(entry.Network)),
|
||||
invokeURI: invokeURI,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].gatewayID != candidates[j].gatewayID {
|
||||
return candidates[i].gatewayID < candidates[j].gatewayID
|
||||
}
|
||||
if candidates[i].instanceID != candidates[j].instanceID {
|
||||
return candidates[i].instanceID < candidates[j].instanceID
|
||||
}
|
||||
return candidates[i].invokeURI < candidates[j].invokeURI
|
||||
})
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) {
|
||||
wallet := resp.GetWallet()
|
||||
if wallet == nil || wallet.GetAsset() == nil {
|
||||
return "", merrors.NoData("managed wallet asset is missing")
|
||||
asset, err := managedWalletAssetFromResponse(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain())))
|
||||
network := strings.ToUpper(strings.TrimSpace(asset.GetChain()))
|
||||
if network == "" || network == "UNSPECIFIED" {
|
||||
return "", merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func managedWalletAssetFromResponse(resp *chainv1.GetManagedWalletResponse) (*paymenttypes.Asset, error) {
|
||||
wallet := resp.GetWallet()
|
||||
if wallet == nil || wallet.GetAsset() == nil {
|
||||
return nil, merrors.NoData("managed wallet asset is missing")
|
||||
}
|
||||
network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain())))
|
||||
if network == "" || network == "UNSPECIFIED" {
|
||||
return nil, merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
return &paymenttypes.Asset{
|
||||
Chain: network,
|
||||
TokenSymbol: strings.ToUpper(strings.TrimSpace(wallet.GetAsset().GetTokenSymbol())),
|
||||
ContractAddress: strings.TrimSpace(wallet.GetAsset().GetContractAddress()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isManagedWalletNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -169,18 +169,18 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
}
|
||||
|
||||
expires := make([]time.Time, 0, len(details))
|
||||
intents := make([]model.PaymentIntent, 0, len(details))
|
||||
snapshots := make([]*model.PaymentQuoteSnapshot, 0, len(details))
|
||||
statuses := make([]*quote_persistence_service.StatusInput, 0, len(details))
|
||||
items := make([]quote_persistence_service.PersistItemInput, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil {
|
||||
logger.Warn("ProcessQuotePayments contains incomplete detail")
|
||||
return nil, merrors.InvalidArgument("batch processing detail is incomplete")
|
||||
}
|
||||
expires = append(expires, detail.ExpiresAt)
|
||||
intents = append(intents, detail.Intent)
|
||||
snapshots = append(snapshots, quoteSnapshotFromComputed(detail.Quote))
|
||||
statuses = append(statuses, statusInputFromStatus(detail.Status))
|
||||
items = append(items, quote_persistence_service.PersistItemInput{
|
||||
Intent: pointerTo(detail.Intent),
|
||||
Quote: quoteSnapshotFromComputed(detail.Quote),
|
||||
Status: statusInputFromStatus(detail.Status),
|
||||
})
|
||||
}
|
||||
|
||||
expiresAt, ok := minExpiry(expires)
|
||||
@@ -195,9 +195,8 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
IdempotencyKey: requestCtx.IdempotencyKey,
|
||||
Hash: fingerprint,
|
||||
ExpiresAt: expiresAt,
|
||||
Intents: intents,
|
||||
Quotes: snapshots,
|
||||
Statuses: statuses,
|
||||
RequestShape: model.QuoteRequestShapeBatch,
|
||||
Items: items,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayments failed to build persistence record", zap.Error(err))
|
||||
@@ -244,7 +243,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
result.Record = stored
|
||||
logger.Info("ProcessQuotePayments persisted quote batch",
|
||||
zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)),
|
||||
zap.Int("quotes_count", len(stored.Quotes)),
|
||||
zap.Int("quotes_count", len(stored.Items)),
|
||||
zap.Time("expires_at", stored.ExpiresAt),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
|
||||
@@ -181,9 +181,14 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
IdempotencyKey: requestCtx.IdempotencyKey,
|
||||
Hash: fingerprint,
|
||||
ExpiresAt: expiresAt,
|
||||
Intent: pointerTo(detail.Intent),
|
||||
Quote: quoteSnapshotFromComputed(detail.Quote),
|
||||
Status: statusInputFromStatus(detail.Status),
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []quote_persistence_service.PersistItemInput{
|
||||
{
|
||||
Intent: pointerTo(detail.Intent),
|
||||
Quote: quoteSnapshotFromComputed(detail.Quote),
|
||||
Status: statusInputFromStatus(detail.Status),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayment failed to build persistence record", zap.Error(err))
|
||||
|
||||
@@ -13,28 +13,34 @@ func (s *QuotationServiceV2) singleResultFromRecord(record *model.PaymentQuoteRe
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("record is required")
|
||||
}
|
||||
if record.Quote == nil {
|
||||
if record.RequestShape != model.QuoteRequestShapeSingle {
|
||||
return nil, merrors.InvalidArgument("record request shape is not single")
|
||||
}
|
||||
if len(record.Items) != 1 || record.Items[0] == nil {
|
||||
return nil, merrors.InvalidArgument("record single item is required")
|
||||
}
|
||||
item := record.Items[0]
|
||||
if item.Intent == nil {
|
||||
return nil, merrors.InvalidArgument("record intent is required")
|
||||
}
|
||||
if item.Quote == nil {
|
||||
return nil, merrors.InvalidArgument("record quote is required")
|
||||
}
|
||||
|
||||
status := statusFromStored(record.StatusV2)
|
||||
status := statusFromStored(item.Status)
|
||||
mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{
|
||||
Meta: quote_response_mapper_v2.QuoteMeta{
|
||||
ID: record.GetID().Hex(),
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
},
|
||||
Quote: canonicalFromSnapshot(record.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
intentRef := strings.TrimSpace(record.Intent.Ref)
|
||||
if len(record.Intents) == 1 {
|
||||
intentRef = firstNonEmpty(strings.TrimSpace(record.Intents[0].Ref), intentRef)
|
||||
}
|
||||
mapped.Quote.IntentRef = intentRef
|
||||
mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref)
|
||||
return &QuotePaymentResult{
|
||||
Response: "ationv2.QuotePaymentResponse{
|
||||
Quote: mapped.Quote,
|
||||
@@ -48,17 +54,22 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("record is required")
|
||||
}
|
||||
if len(record.Quotes) == 0 {
|
||||
return nil, merrors.InvalidArgument("record quotes are required")
|
||||
if record.RequestShape != model.QuoteRequestShapeBatch {
|
||||
return nil, merrors.InvalidArgument("record request shape is not batch")
|
||||
}
|
||||
if len(record.Items) == 0 {
|
||||
return nil, merrors.InvalidArgument("record items are required")
|
||||
}
|
||||
|
||||
quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Quotes))
|
||||
for idx, snapshot := range record.Quotes {
|
||||
var storedStatus *model.QuoteStatusV2
|
||||
if idx < len(record.StatusesV2) {
|
||||
storedStatus = record.StatusesV2[idx]
|
||||
quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Items))
|
||||
for _, item := range record.Items {
|
||||
if item == nil {
|
||||
return nil, merrors.InvalidArgument("record item is required")
|
||||
}
|
||||
status := statusFromStored(storedStatus)
|
||||
if item.Quote == nil {
|
||||
return nil, merrors.InvalidArgument("record item quote is required")
|
||||
}
|
||||
status := statusFromStored(item.Status)
|
||||
|
||||
mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{
|
||||
Meta: quote_response_mapper_v2.QuoteMeta{
|
||||
@@ -66,14 +77,14 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
},
|
||||
Quote: canonicalFromSnapshot(snapshot, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idx < len(record.Intents) {
|
||||
mapped.Quote.IntentRef = strings.TrimSpace(record.Intents[idx].Ref)
|
||||
if item.Intent != nil {
|
||||
mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref)
|
||||
}
|
||||
quotes = append(quotes, mapped.Quote)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
@@ -188,6 +189,131 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
t.Logf("single response:\n%s", mustProtoJSON(t, result.Response))
|
||||
}
|
||||
|
||||
func TestQuotePayment_FixReceivedRUB_ProducesUSDTDebit_EndToEnd(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
store := newInMemoryQuotesStore()
|
||||
core := &fakeQuoteCore{now: now}
|
||||
svc := New(Dependencies{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
QuotesStore: store,
|
||||
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
|
||||
return "q-intent-fix-received"
|
||||
})),
|
||||
Computation: quote_computation_service.New(
|
||||
core,
|
||||
quote_computation_service.WithManagedWalletNetworkResolver(staticManagedWalletResolverForE2E{
|
||||
assetsByRef: map[string]*paymenttypes.Asset{
|
||||
"wallet-usdt-source": {
|
||||
Chain: "TRON_NILE",
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
Now: func() time.Time { return now },
|
||||
NewRef: func() string { return "quote-fix-received-rub" },
|
||||
})
|
||||
|
||||
intent := makeTransferIntent(t, "5000", "RUB", "wallet-usdt-source", "4111111111111111", "RU")
|
||||
intent.SettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
|
||||
intent.SettlementCurrency = "RUB"
|
||||
|
||||
req := "ationv2.QuotePaymentRequest{
|
||||
Meta: &sharedv1.RequestMeta{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
},
|
||||
IdempotencyKey: "idem-fix-received-rub",
|
||||
InitiatorRef: "initiator-42",
|
||||
PreviewOnly: false,
|
||||
Intent: intent,
|
||||
}
|
||||
|
||||
result, err := svc.ProcessQuotePayment(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessQuotePayment returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.Response == nil || result.Response.GetQuote() == nil {
|
||||
t.Fatalf("expected quote response")
|
||||
}
|
||||
quote := result.Response.GetQuote()
|
||||
|
||||
rate := decimal.RequireFromString("91.5")
|
||||
received := decimal.RequireFromString("5000")
|
||||
expectedPrincipal := received.Div(rate)
|
||||
|
||||
feeTotal := decimal.Zero
|
||||
for _, line := range quote.GetFeeLines() {
|
||||
if line == nil || line.GetMoney() == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(line.GetMoney().GetCurrency(), "USDT") {
|
||||
continue
|
||||
}
|
||||
lineAmount := decimal.RequireFromString(line.GetMoney().GetAmount())
|
||||
switch line.GetSide() {
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
||||
feeTotal = feeTotal.Sub(lineAmount)
|
||||
default:
|
||||
feeTotal = feeTotal.Add(lineAmount)
|
||||
}
|
||||
}
|
||||
expectedTotalDebit := expectedPrincipal.Add(feeTotal)
|
||||
|
||||
if got, want := quote.GetTransferPrincipalAmount().GetAmount(), expectedPrincipal.String(); got != want {
|
||||
t.Fatalf("unexpected principal amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected principal currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetDestinationAmount().GetAmount(), "5000"; got != want {
|
||||
t.Fatalf("unexpected destination amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want {
|
||||
t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), expectedTotalDebit.String(); got != want {
|
||||
t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected payer_total_debit_amount currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
|
||||
t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if quote.GetRoute() == nil || quote.GetRoute().GetSettlement() == nil {
|
||||
t.Fatalf("expected route settlement")
|
||||
}
|
||||
if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_received"; got != want {
|
||||
t.Fatalf("unexpected route settlement model: got=%q want=%q", got, want)
|
||||
}
|
||||
if quote.GetFxQuote() == nil {
|
||||
t.Fatalf("expected fx quote")
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected fx base: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want {
|
||||
t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want {
|
||||
t.Fatalf("unexpected fx side: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetQuoteAmount().GetAmount(), "5000"; got != want {
|
||||
t.Fatalf("unexpected fx quote amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetQuoteAmount().GetCurrency(), "RUB"; got != want {
|
||||
t.Fatalf("unexpected fx quote amount currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetBaseAmount().GetAmount(), expectedPrincipal.String(); got != want {
|
||||
t.Fatalf("unexpected fx base amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetBaseAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected fx base amount currency: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
orgID := bson.NewObjectID()
|
||||
@@ -605,29 +731,70 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
return nil, time.Time{}, fmt.Errorf("route hops are required for route-bound quote pricing")
|
||||
}
|
||||
|
||||
baseAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount())
|
||||
intentAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount())
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(in.Intent.Amount.GetCurrency()))
|
||||
rate := decimal.RequireFromString("91.5")
|
||||
quoteAmount := baseAmount.Mul(rate)
|
||||
|
||||
baseCurrency := "USDT"
|
||||
quoteCurrency := "RUB"
|
||||
fxSide := fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
if in.Intent.FX != nil && in.Intent.FX.Pair != nil {
|
||||
if base := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetBase())); base != "" {
|
||||
baseCurrency = base
|
||||
}
|
||||
if quote := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetQuote())); quote != "" {
|
||||
quoteCurrency = quote
|
||||
}
|
||||
switch in.Intent.FX.Side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
fxSide = fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
default:
|
||||
fxSide = fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
}
|
||||
}
|
||||
|
||||
baseAmount := intentAmount
|
||||
quoteAmount := intentAmount.Mul(rate)
|
||||
switch {
|
||||
case strings.EqualFold(amountCurrency, quoteCurrency):
|
||||
quoteAmount = intentAmount
|
||||
baseAmount = intentAmount.Div(rate)
|
||||
case strings.EqualFold(amountCurrency, baseCurrency):
|
||||
baseAmount = intentAmount
|
||||
quoteAmount = intentAmount.Mul(rate)
|
||||
}
|
||||
|
||||
payAmount := baseAmount
|
||||
payCurrency := baseCurrency
|
||||
settlementAmount := quoteAmount
|
||||
settlementCurrency := quoteCurrency
|
||||
if fxSide == fxv1.Side_BUY_BASE_SELL_QUOTE {
|
||||
payAmount = quoteAmount
|
||||
payCurrency = quoteCurrency
|
||||
settlementAmount = baseAmount
|
||||
settlementCurrency = baseCurrency
|
||||
}
|
||||
|
||||
feeAmount := decimal.RequireFromString("1.50")
|
||||
taxAmount := decimal.RequireFromString("0.30")
|
||||
if routeFeeClass(in.Route) != "card_payout:3_hops:monetix" {
|
||||
if routeFeeClass(in.Route) != "card_payout:3_hops:"+paymenttypes.DefaultCardsGatewayID {
|
||||
feeAmount = decimal.RequireFromString("2.00")
|
||||
taxAmount = decimal.RequireFromString("0.40")
|
||||
}
|
||||
|
||||
quote := "e_computation_service.ComputedQuote{
|
||||
DebitAmount: &moneyv1.Money{
|
||||
Amount: baseAmount.String(),
|
||||
Currency: "USDT",
|
||||
Amount: payAmount.String(),
|
||||
Currency: payCurrency,
|
||||
},
|
||||
CreditAmount: &moneyv1.Money{
|
||||
Amount: quoteAmount.String(),
|
||||
Currency: "RUB",
|
||||
Amount: settlementAmount.String(),
|
||||
Currency: settlementCurrency,
|
||||
},
|
||||
FeeLines: []*feesv1.DerivedPostingLine{
|
||||
{
|
||||
LedgerAccountRef: "ledger:fees:usdt",
|
||||
Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: "USDT"},
|
||||
Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: payCurrency},
|
||||
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
|
||||
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
|
||||
Meta: map[string]string{
|
||||
@@ -637,7 +804,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
},
|
||||
{
|
||||
LedgerAccountRef: "ledger:tax:usdt",
|
||||
Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: "USDT"},
|
||||
Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: payCurrency},
|
||||
LineType: accountingv1.PostingLineType_POSTING_LINE_TAX,
|
||||
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
|
||||
Meta: map[string]string{
|
||||
@@ -664,13 +831,13 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
FXQuote: &oraclev1.Quote{
|
||||
QuoteRef: "fx-usdt-rub",
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "RUB",
|
||||
Base: baseCurrency,
|
||||
Quote: quoteCurrency,
|
||||
},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
Side: fxSide,
|
||||
Price: &moneyv1.Decimal{Value: rate.String()},
|
||||
BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"},
|
||||
QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: "RUB"},
|
||||
BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: baseCurrency},
|
||||
QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: quoteCurrency},
|
||||
ExpiresAtUnixMs: f.now.Add(f.fxTTLValue()).UnixMilli(),
|
||||
Provider: "test-oracle",
|
||||
RateRef: "rate-usdt-rub",
|
||||
@@ -815,6 +982,30 @@ type staticGatewayRegistryForE2E struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
type staticManagedWalletResolverForE2E struct {
|
||||
assetsByRef map[string]*paymenttypes.Asset
|
||||
}
|
||||
|
||||
func (r staticManagedWalletResolverForE2E) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
|
||||
if len(r.assetsByRef) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
asset, ok := r.assetsByRef[strings.TrimSpace(managedWalletRef)]
|
||||
if !ok || asset == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cloned := *asset
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
func (r staticManagedWalletResolverForE2E) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) {
|
||||
asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef)
|
||||
if err != nil || asset == nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(asset.GetChain()), nil
|
||||
}
|
||||
|
||||
func (r staticGatewayRegistryForE2E) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
if len(r.items) == 0 {
|
||||
return nil, nil
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
|
||||
svc := New(nil, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
|
||||
GatewayModes: map[string]model.FundingMode{
|
||||
"monetix": model.FundingModeBalanceReserve,
|
||||
paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve,
|
||||
},
|
||||
})))
|
||||
|
||||
@@ -168,6 +168,69 @@ func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_UsesSourceAssetCurrencyForSourceStep(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.Amount = &paymenttypes.Money{
|
||||
Amount: "5000",
|
||||
Currency: "RUB",
|
||||
}
|
||||
intent.SettlementCurrency = "RUB"
|
||||
intent.RequiresFX = false
|
||||
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-key",
|
||||
PreviewOnly: false,
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(planModel.Items) != 1 {
|
||||
t.Fatalf("expected one plan item")
|
||||
}
|
||||
item := planModel.Items[0]
|
||||
if item == nil {
|
||||
t.Fatalf("expected plan item")
|
||||
}
|
||||
if !item.QuoteInput.Intent.RequiresFX {
|
||||
t.Fatalf("expected derived FX requirement for fix_received cross-currency flow")
|
||||
}
|
||||
if item.QuoteInput.Intent.FX == nil || item.QuoteInput.Intent.FX.Pair == nil {
|
||||
t.Fatalf("expected derived FX pair")
|
||||
}
|
||||
if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetBase()), "USDT"; got != want {
|
||||
t.Fatalf("unexpected derived FX base currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetQuote()), "RUB"; got != want {
|
||||
t.Fatalf("unexpected derived FX quote currency: got=%q want=%q", got, want)
|
||||
}
|
||||
steps := item.Steps
|
||||
if got, want := len(steps), 4; got != want {
|
||||
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
|
||||
}
|
||||
if steps[0] == nil || steps[0].Amount == nil {
|
||||
t.Fatalf("expected source step amount")
|
||||
}
|
||||
if got, want := strings.TrimSpace(steps[0].Amount.GetCurrency()), "USDT"; got != want {
|
||||
t.Fatalf("unexpected source step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
last := steps[len(steps)-1]
|
||||
if last == nil || last.Amount == nil {
|
||||
t.Fatalf("expected destination step amount")
|
||||
}
|
||||
if got, want := strings.TrimSpace(last.Amount.GetCurrency()), "RUB"; got != want {
|
||||
t.Fatalf("unexpected destination step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[1].Operation, model.RailOperationFXConvert; got != want {
|
||||
t.Fatalf("unexpected middle operation: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ResolvesIndependentEconomicsKnobs(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
@@ -397,7 +460,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
}
|
||||
svc := New(core, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
|
||||
GatewayModes: map[string]model.FundingMode{
|
||||
"monetix": model.FundingModeBalanceReserve,
|
||||
paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve,
|
||||
},
|
||||
})))
|
||||
|
||||
@@ -459,7 +522,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
if got, want := len(hops), 2; got != want {
|
||||
t.Fatalf("unexpected route hops in build input: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := hops[1].GetGateway(), "monetix"; got != want {
|
||||
if got, want := hops[1].GetGateway(), paymenttypes.DefaultCardsGatewayID; got != want {
|
||||
t.Fatalf("unexpected destination gateway in build input route: got=%q want=%q", got, want)
|
||||
}
|
||||
if core.lastQuoteIn.ExecutionConditions == nil {
|
||||
@@ -611,7 +674,7 @@ func sampleCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent {
|
||||
},
|
||||
SettlementCurrency: "USD",
|
||||
Attributes: map[string]string{
|
||||
"gateway": "monetix",
|
||||
"gateway": paymenttypes.DefaultCardsGatewayID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent {
|
||||
@@ -22,6 +23,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
||||
Source: modelEndpointFromQuoteEndpoint(src.Source),
|
||||
Destination: modelEndpointFromQuoteEndpoint(src.Destination),
|
||||
Amount: cloneModelMoney(src.Amount),
|
||||
FX: fxIntentFromHydratedIntent(src),
|
||||
RequiresFX: src.RequiresFX,
|
||||
Attributes: cloneStringMap(src.Attributes),
|
||||
SettlementMode: modelSettlementMode(src.SettlementMode),
|
||||
@@ -30,6 +32,72 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
||||
}
|
||||
}
|
||||
|
||||
func fxIntentFromHydratedIntent(src *transfer_intent_hydrator.QuoteIntent) *model.FXIntent {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(string(src.FXSide)) == "" || src.FXSide == paymenttypes.FXSideUnspecified {
|
||||
return nil
|
||||
}
|
||||
return &model.FXIntent{Side: src.FXSide}
|
||||
}
|
||||
|
||||
func ensureDerivedFXIntent(intent *model.PaymentIntent) {
|
||||
if intent == nil {
|
||||
return
|
||||
}
|
||||
|
||||
amountCurrency := ""
|
||||
if intent.Amount != nil {
|
||||
amountCurrency = normalizeAsset(intent.Amount.GetCurrency())
|
||||
}
|
||||
settlementCurrency := normalizeAsset(intent.SettlementCurrency)
|
||||
if settlementCurrency == "" {
|
||||
settlementCurrency = amountCurrency
|
||||
}
|
||||
if intent.SettlementCurrency == "" && settlementCurrency != "" {
|
||||
intent.SettlementCurrency = settlementCurrency
|
||||
}
|
||||
|
||||
sourceCurrency := sourceAssetToken(intent.Source)
|
||||
|
||||
// For FIX_RECEIVED, destination amounts can be provided in payout currency.
|
||||
// Derive FX necessity from source asset currency when available.
|
||||
if !intent.RequiresFX &&
|
||||
intent.SettlementMode == model.SettlementModeFixReceived &&
|
||||
sourceCurrency != "" &&
|
||||
settlementCurrency != "" &&
|
||||
!strings.EqualFold(sourceCurrency, settlementCurrency) {
|
||||
intent.RequiresFX = true
|
||||
}
|
||||
|
||||
if !intent.RequiresFX {
|
||||
return
|
||||
}
|
||||
|
||||
baseCurrency := firstNonEmpty(sourceCurrency, amountCurrency)
|
||||
quoteCurrency := settlementCurrency
|
||||
if baseCurrency == "" || quoteCurrency == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if intent.FX == nil {
|
||||
intent.FX = &model.FXIntent{}
|
||||
}
|
||||
if intent.FX.Pair == nil {
|
||||
intent.FX.Pair = &paymenttypes.CurrencyPair{}
|
||||
}
|
||||
if normalizeAsset(intent.FX.Pair.Base) == "" {
|
||||
intent.FX.Pair.Base = baseCurrency
|
||||
}
|
||||
if normalizeAsset(intent.FX.Pair.Quote) == "" {
|
||||
intent.FX.Pair.Quote = quoteCurrency
|
||||
}
|
||||
if strings.TrimSpace(string(intent.FX.Side)) == "" || intent.FX.Side == paymenttypes.FXSideUnspecified {
|
||||
intent.FX.Side = paymenttypes.FXSideSellBaseBuyQuote
|
||||
}
|
||||
}
|
||||
|
||||
func modelEndpointFromQuoteEndpoint(src transfer_intent_hydrator.QuoteEndpoint) model.PaymentEndpoint {
|
||||
result := model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeUnspecified,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func TestEnsureDerivedFXIntent_DefaultsSideWhenEmpty(t *testing.T) {
|
||||
intent := &model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
SettlementCurrency: "RUB",
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
FX: &model.FXIntent{},
|
||||
}
|
||||
|
||||
ensureDerivedFXIntent(intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDerivedFXIntent_DefaultsSideWhenUnspecified(t *testing.T) {
|
||||
intent := &model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
SettlementCurrency: "RUB",
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
FX: &model.FXIntent{Side: paymenttypes.FXSideUnspecified},
|
||||
}
|
||||
|
||||
ensureDerivedFXIntent(intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDerivedFXIntent_PreservesExplicitSideFromHydratedIntent(t *testing.T) {
|
||||
hydrated := &transfer_intent_hydrator.QuoteIntent{
|
||||
Source: transfer_intent_hydrator.QuoteEndpoint{
|
||||
Type: transfer_intent_hydrator.QuoteEndpointTypeManagedWallet,
|
||||
ManagedWallet: &transfer_intent_hydrator.QuoteManagedWalletEndpoint{
|
||||
ManagedWalletRef: "mw-src",
|
||||
Asset: &paymenttypes.Asset{TokenSymbol: "USDT"},
|
||||
},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
SettlementCurrency: "RUB",
|
||||
RequiresFX: true,
|
||||
FXSide: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
}
|
||||
|
||||
intent := modelIntentFromQuoteIntent(hydrated)
|
||||
ensureDerivedFXIntent(&intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideBuyBaseSellQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type managedWalletAssetResolver interface {
|
||||
ResolveManagedWalletAsset(ctx context.Context, managedWalletRef string) (*paymenttypes.Asset, error)
|
||||
}
|
||||
|
||||
func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
ctx context.Context,
|
||||
endpoint *model.PaymentEndpoint,
|
||||
@@ -25,7 +29,30 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
if walletRef == "" {
|
||||
return merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
}
|
||||
if endpoint.ManagedWallet.Asset != nil && strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()) != "" {
|
||||
asset := endpoint.ManagedWallet.Asset
|
||||
if asset == nil {
|
||||
asset = &paymenttypes.Asset{}
|
||||
endpoint.ManagedWallet.Asset = asset
|
||||
}
|
||||
if resolver, ok := s.managedWalletNetworkResolver.(managedWalletAssetResolver); ok && resolver != nil {
|
||||
if strings.TrimSpace(asset.GetChain()) == "" || strings.TrimSpace(asset.GetTokenSymbol()) == "" {
|
||||
if resolved, err := resolver.ResolveManagedWalletAsset(ctx, walletRef); err == nil && resolved != nil {
|
||||
if strings.TrimSpace(asset.GetChain()) == "" {
|
||||
asset.Chain = strings.ToUpper(strings.TrimSpace(resolved.GetChain()))
|
||||
}
|
||||
if strings.TrimSpace(asset.GetTokenSymbol()) == "" {
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(resolved.GetTokenSymbol()))
|
||||
}
|
||||
if strings.TrimSpace(asset.GetContractAddress()) == "" {
|
||||
asset.ContractAddress = strings.TrimSpace(resolved.GetContractAddress())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(asset.GetChain()) != "" {
|
||||
asset.Chain = strings.ToUpper(strings.TrimSpace(asset.GetChain()))
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
asset.ContractAddress = strings.TrimSpace(asset.GetContractAddress())
|
||||
return nil
|
||||
}
|
||||
if s.managedWalletNetworkResolver == nil {
|
||||
@@ -57,11 +84,6 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
)
|
||||
}
|
||||
|
||||
asset := endpoint.ManagedWallet.Asset
|
||||
if asset == nil {
|
||||
asset = &paymenttypes.Asset{}
|
||||
endpoint.ManagedWallet.Asset = asset
|
||||
}
|
||||
asset.Chain = network
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.TokenSymbol))
|
||||
asset.ContractAddress = strings.TrimSpace(asset.ContractAddress)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -147,6 +148,93 @@ func TestBuildPlan_ManagedWalletNetworkResolverCachesByWalletRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ResolvesManagedWalletAssetTokenForSourceCurrency(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
networks: map[string]string{
|
||||
"wallet-usdt-source": "TRON_NILE",
|
||||
},
|
||||
assets: map[string]*paymenttypes.Asset{
|
||||
"wallet-usdt-source": {
|
||||
Chain: "TRON_NILE",
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := New(nil,
|
||||
WithManagedWalletNetworkResolver(resolver),
|
||||
WithGatewayRegistry(staticGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-tron",
|
||||
InstanceID: "crypto-tron",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON_NILE",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "fx-tron",
|
||||
InstanceID: "fx-tron",
|
||||
Rail: model.RailProviderSettlement,
|
||||
Network: "TRON_NILE",
|
||||
Currencies: []string{"USDT", "RUB"},
|
||||
Operations: []model.RailOperation{model.RailOperationFXConvert},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "card-gw",
|
||||
InstanceID: "card-gw",
|
||||
Rail: model.RailCardPayout,
|
||||
Currencies: []string{"RUB"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.Source.ManagedWallet.Asset = nil
|
||||
intent.Amount = &paymenttypes.Money{
|
||||
Amount: "5000",
|
||||
Currency: "RUB",
|
||||
}
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.SettlementCurrency = "RUB"
|
||||
intent.RequiresFX = false
|
||||
orgID := bson.NewObjectID()
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-wallet-asset",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil {
|
||||
t.Fatalf("expected one plan item")
|
||||
}
|
||||
item := planModel.Items[0]
|
||||
if got, want := item.Steps[0].GatewayID, "crypto-tron"; got != want {
|
||||
t.Fatalf("unexpected source gateway: got=%q want=%q", got, want)
|
||||
}
|
||||
if item.Steps[0] == nil || item.Steps[0].Amount == nil {
|
||||
t.Fatalf("expected source step amount")
|
||||
}
|
||||
if got, want := item.Steps[0].Amount.GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected source step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resolver.assetCalls, 1; got != want {
|
||||
t.Fatalf("unexpected asset resolver calls: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
err: merrors.NoData("wallet not found"),
|
||||
@@ -168,9 +256,12 @@ func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeManagedWalletNetworkResolver struct {
|
||||
networks map[string]string
|
||||
err error
|
||||
calls int
|
||||
networks map[string]string
|
||||
assets map[string]*paymenttypes.Asset
|
||||
err error
|
||||
assetErr error
|
||||
calls int
|
||||
assetCalls int
|
||||
}
|
||||
|
||||
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context.Context, managedWalletRef string) (string, error) {
|
||||
@@ -183,3 +274,22 @@ func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context
|
||||
}
|
||||
return f.networks[managedWalletRef], nil
|
||||
}
|
||||
|
||||
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
|
||||
f.assetCalls++
|
||||
if f.assetErr != nil {
|
||||
return nil, f.assetErr
|
||||
}
|
||||
if f.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
src := f.assets[managedWalletRef]
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &paymenttypes.Asset{
|
||||
Chain: src.GetChain(),
|
||||
TokenSymbol: src.GetTokenSymbol(),
|
||||
ContractAddress: src.GetContractAddress(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
}
|
||||
modelIntent.Source = clonePaymentEndpoint(source)
|
||||
modelIntent.Destination = clonePaymentEndpoint(destination)
|
||||
ensureDerivedFXIntent(&modelIntent)
|
||||
|
||||
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ func buildComputationSteps(
|
||||
|
||||
attrs := intent.Attributes
|
||||
amount := protoMoneyFromModel(intent.Amount)
|
||||
sourceAmount := sourceStepAmount(intent, amount)
|
||||
destinationAmount := destinationStepAmount(intent, amount)
|
||||
sourceRail := sourceRailForIntent(intent)
|
||||
destinationRail := destinationRailForIntent(intent)
|
||||
@@ -45,7 +47,7 @@ func buildComputationSteps(
|
||||
Operation: sourceOperationForRail(rails[0]),
|
||||
GatewayID: sourceGatewayID,
|
||||
InstanceID: sourceInstanceID,
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(sourceAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
},
|
||||
@@ -63,7 +65,7 @@ func buildComputationSteps(
|
||||
Rail: model.RailProviderSettlement,
|
||||
Operation: model.RailOperationFXConvert,
|
||||
DependsOn: []string{sourceStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(sourceAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
@@ -82,12 +84,16 @@ func buildComputationSteps(
|
||||
operation = model.RailOperationFXConvert
|
||||
fxAssigned = true
|
||||
}
|
||||
stepAmount := amount
|
||||
if operation == model.RailOperationFXConvert {
|
||||
stepAmount = sourceAmount
|
||||
}
|
||||
steps = append(steps, &QuoteComputationStep{
|
||||
StepID: stepID,
|
||||
Rail: rail,
|
||||
Operation: operation,
|
||||
DependsOn: []string{lastStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(stepAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
@@ -209,11 +215,78 @@ func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Mon
|
||||
}
|
||||
|
||||
settlementCurrency := strings.ToUpper(strings.TrimSpace(intent.SettlementCurrency))
|
||||
if settlementCurrency == "" && intent.FX != nil && intent.FX.Pair != nil {
|
||||
settlementCurrency = strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote))
|
||||
if settlementCurrency == "" {
|
||||
settlementCurrency = settlementCurrencyFromFX(intent.FX)
|
||||
}
|
||||
if settlementCurrency != "" {
|
||||
amount.Currency = settlementCurrency
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
func settlementCurrencyFromFX(fx *model.FXIntent) string {
|
||||
if fx == nil || fx.Pair == nil {
|
||||
return ""
|
||||
}
|
||||
base := normalizeAsset(fx.Pair.GetBase())
|
||||
quote := normalizeAsset(fx.Pair.GetQuote())
|
||||
switch fx.Side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return firstNonEmpty(base, quote)
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return firstNonEmpty(quote, base)
|
||||
default:
|
||||
return firstNonEmpty(quote, base)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceStepAmount(intent model.PaymentIntent, amount *moneyv1.Money) *moneyv1.Money {
|
||||
result := cloneProtoMoney(amount)
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if currency := sourceStepCurrency(intent, result.GetCurrency()); currency != "" {
|
||||
result.Currency = currency
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sourceStepCurrency(intent model.PaymentIntent, fallback string) string {
|
||||
if currency := sourceCurrencyFromFX(intent.FX); currency != "" {
|
||||
return currency
|
||||
}
|
||||
if currency := sourceAssetToken(intent.Source); currency != "" {
|
||||
return currency
|
||||
}
|
||||
return normalizeAsset(fallback)
|
||||
}
|
||||
|
||||
func sourceCurrencyFromFX(fx *model.FXIntent) string {
|
||||
if fx == nil || fx.Pair == nil {
|
||||
return ""
|
||||
}
|
||||
base := normalizeAsset(fx.Pair.GetBase())
|
||||
quote := normalizeAsset(fx.Pair.GetQuote())
|
||||
switch fx.Side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return quote
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return base
|
||||
default:
|
||||
return firstNonEmpty(base, quote)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceAssetToken(endpoint model.PaymentEndpoint) string {
|
||||
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
|
||||
if token := normalizeAsset(endpoint.ManagedWallet.Asset.GetTokenSymbol()); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
|
||||
if token := normalizeAsset(endpoint.ExternalChain.Asset.GetTokenSymbol()); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestDestinationStepAmount_UsesSideAwareCurrencyFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side paymenttypes.FXSide
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "buy_base_sell_quote uses base settlement currency",
|
||||
side: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
want: "RUB",
|
||||
},
|
||||
{
|
||||
name: "sell_base_buy_quote uses quote settlement currency",
|
||||
side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
want: "USDT",
|
||||
},
|
||||
{
|
||||
name: "unspecified defaults to quote settlement currency",
|
||||
side: paymenttypes.FXSideUnspecified,
|
||||
want: "USDT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
intent := model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
FX: &model.FXIntent{
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
},
|
||||
Side: tt.side,
|
||||
},
|
||||
}
|
||||
|
||||
got := destinationStepAmount(intent, &moneyv1.Money{
|
||||
Amount: "100",
|
||||
Currency: "EUR",
|
||||
})
|
||||
if got == nil {
|
||||
t.Fatal("expected destination amount")
|
||||
}
|
||||
if got.GetCurrency() != tt.want {
|
||||
t.Fatalf("unexpected destination currency: got=%q want=%q", got.GetCurrency(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCurrencyFromFX_RespectsSide(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side paymenttypes.FXSide
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "buy_base_sell_quote debits quote currency",
|
||||
side: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
want: "USDT",
|
||||
},
|
||||
{
|
||||
name: "sell_base_buy_quote debits base currency",
|
||||
side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
want: "RUB",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sourceCurrencyFromFX(&model.FXIntent{
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
},
|
||||
Side: tt.side,
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("unexpected source currency: got=%q want=%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -104,10 +104,10 @@ func shapeMatches(record *model.PaymentQuoteRecord, shape QuoteShape) bool {
|
||||
|
||||
switch shape {
|
||||
case QuoteShapeSingle:
|
||||
return len(record.Quotes) == 0
|
||||
return record.RequestShape == model.QuoteRequestShapeSingle && len(record.Items) == 1
|
||||
case QuoteShapeBatch:
|
||||
return len(record.Quotes) > 0
|
||||
return record.RequestShape == model.QuoteRequestShapeBatch && len(record.Items) > 0
|
||||
default:
|
||||
return true
|
||||
return record.RequestShape != model.QuoteRequestShapeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,7 @@ func TestTryReuse_ParamMismatch(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "stored-hash",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}, nil
|
||||
return testSingleRecord("stored-hash", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,11 +58,7 @@ func TestTryReuse_ShapeMismatch(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}, nil
|
||||
return testSingleRecord("hash-1", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -85,13 +77,7 @@ func TestTryReuse_ShapeMismatchSingle(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
},
|
||||
}, nil
|
||||
return testBatchRecord("hash-1", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -108,11 +94,7 @@ func TestTryReuse_ShapeMismatchSingle(t *testing.T) {
|
||||
|
||||
func TestTryReuse_Success(t *testing.T) {
|
||||
svc := New()
|
||||
existing := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}
|
||||
existing := testSingleRecord("hash-1", "q1")
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return existing, nil
|
||||
@@ -141,11 +123,7 @@ func TestCreateOrReuse_CreateSuccess(t *testing.T) {
|
||||
store := &fakeQuotesStore{
|
||||
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return nil },
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}
|
||||
record := testSingleRecord("hash-1", "q1")
|
||||
|
||||
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: record,
|
||||
@@ -169,22 +147,14 @@ func TestCreateOrReuse_CreateSuccess(t *testing.T) {
|
||||
|
||||
func TestCreateOrReuse_DuplicateReturnsExisting(t *testing.T) {
|
||||
svc := New()
|
||||
existing := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}
|
||||
existing := testSingleRecord("hash-1", "q1")
|
||||
store := &fakeQuotesStore{
|
||||
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote },
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return existing, nil
|
||||
},
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
}
|
||||
record := testSingleRecord("hash-1", "q2")
|
||||
|
||||
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: record,
|
||||
@@ -211,20 +181,12 @@ func TestCreateOrReuse_DuplicateParamMismatch(t *testing.T) {
|
||||
store := &fakeQuotesStore{
|
||||
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote },
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "stored-hash",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}, nil
|
||||
return testSingleRecord("stored-hash", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "new-hash",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
},
|
||||
Record: testSingleRecord("new-hash", "q2"),
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
@@ -247,11 +209,7 @@ func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing
|
||||
}
|
||||
|
||||
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
},
|
||||
Record: testSingleRecord("hash-1", "q2"),
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
@@ -286,3 +244,33 @@ func (f *fakeQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.O
|
||||
}
|
||||
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
|
||||
}
|
||||
|
||||
func testSingleRecord(hash, quoteRef string) *model.PaymentQuoteRecord {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: hash,
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []*model.PaymentQuoteItemV2{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: quoteRef},
|
||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testBatchRecord(hash, quoteRef string) *model.PaymentQuoteRecord {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: hash,
|
||||
RequestShape: model.QuoteRequestShapeBatch,
|
||||
Items: []*model.PaymentQuoteItemV2{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: quoteRef},
|
||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,18 @@ type StatusInput struct {
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
type PersistItemInput struct {
|
||||
Intent *model.PaymentIntent
|
||||
Quote *model.PaymentQuoteSnapshot
|
||||
Status *StatusInput
|
||||
}
|
||||
|
||||
type PersistInput struct {
|
||||
OrganizationID bson.ObjectID
|
||||
QuoteRef string
|
||||
IdempotencyKey string
|
||||
Hash string
|
||||
ExpiresAt time.Time
|
||||
|
||||
Intent *model.PaymentIntent
|
||||
Intents []model.PaymentIntent
|
||||
|
||||
Quote *model.PaymentQuoteSnapshot
|
||||
Quotes []*model.PaymentQuoteSnapshot
|
||||
|
||||
Status *StatusInput
|
||||
Statuses []*StatusInput
|
||||
RequestShape model.QuoteRequestShape
|
||||
Items []PersistItemInput
|
||||
}
|
||||
|
||||
@@ -52,53 +52,46 @@ func (s *QuotePersistenceService) BuildRecord(in PersistInput) (*model.PaymentQu
|
||||
if in.ExpiresAt.IsZero() {
|
||||
return nil, merrors.InvalidArgument("expires_at is required")
|
||||
}
|
||||
|
||||
isSingle := in.Quote != nil
|
||||
isBatch := len(in.Quotes) > 0
|
||||
|
||||
if isSingle == isBatch {
|
||||
return nil, merrors.InvalidArgument("exactly one quote shape is required")
|
||||
switch in.RequestShape {
|
||||
case model.QuoteRequestShapeSingle:
|
||||
if len(in.Items) != 1 {
|
||||
return nil, merrors.InvalidArgument("single shape requires exactly one item")
|
||||
}
|
||||
case model.QuoteRequestShapeBatch:
|
||||
if len(in.Items) == 0 {
|
||||
return nil, merrors.InvalidArgument("batch shape requires at least one item")
|
||||
}
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("request_shape is required")
|
||||
}
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: strings.TrimSpace(in.QuoteRef),
|
||||
IdempotencyKey: strings.TrimSpace(in.IdempotencyKey),
|
||||
RequestShape: in.RequestShape,
|
||||
Hash: strings.TrimSpace(in.Hash),
|
||||
ExpiresAt: in.ExpiresAt,
|
||||
Items: make([]*model.PaymentQuoteItemV2, 0, len(in.Items)),
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(in.OrganizationID)
|
||||
|
||||
if isSingle {
|
||||
if in.Intent == nil {
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
for idx, item := range in.Items {
|
||||
if item.Intent == nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].intent is required")
|
||||
}
|
||||
status, err := mapStatusInput(in.Status)
|
||||
if item.Quote == nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].quote is required")
|
||||
}
|
||||
status, err := mapStatusInput(item.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "]." + err.Error())
|
||||
}
|
||||
record.Intent = *in.Intent
|
||||
record.Quote = in.Quote
|
||||
record.StatusV2 = status
|
||||
return record, nil
|
||||
record.Items = append(record.Items, &model.PaymentQuoteItemV2{
|
||||
Intent: item.Intent,
|
||||
Quote: item.Quote,
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
if len(in.Intents) == 0 {
|
||||
return nil, merrors.InvalidArgument("intents are required")
|
||||
}
|
||||
if len(in.Intents) != len(in.Quotes) {
|
||||
return nil, merrors.InvalidArgument("intents and quotes count mismatch")
|
||||
}
|
||||
statuses, err := mapStatusInputs(in.Statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(statuses) != len(in.Quotes) {
|
||||
return nil, merrors.InvalidArgument("statuses and quotes count mismatch")
|
||||
}
|
||||
|
||||
record.Intents = in.Intents
|
||||
record.Quotes = in.Quotes
|
||||
record.StatusesV2 = statuses
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@@ -24,12 +24,17 @@ func TestPersistSingle(t *testing.T) {
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -41,17 +46,20 @@ func TestPersistSingle(t *testing.T) {
|
||||
if store.created == nil {
|
||||
t.Fatalf("expected record to be created")
|
||||
}
|
||||
if store.created.ExecutionNote != "" {
|
||||
t.Fatalf("expected no legacy execution note, got %q", store.created.ExecutionNote)
|
||||
if store.created.RequestShape != model.QuoteRequestShapeSingle {
|
||||
t.Fatalf("unexpected request shape: %q", store.created.RequestShape)
|
||||
}
|
||||
if store.created.StatusV2 == nil {
|
||||
t.Fatalf("expected v2 status metadata")
|
||||
if len(store.created.Items) != 1 || store.created.Items[0] == nil {
|
||||
t.Fatalf("expected single persisted item")
|
||||
}
|
||||
if store.created.StatusV2.State != model.QuoteStateExecutable {
|
||||
t.Fatalf("unexpected state: %q", store.created.StatusV2.State)
|
||||
if store.created.Items[0].Status == nil {
|
||||
t.Fatalf("expected item status metadata")
|
||||
}
|
||||
if store.created.StatusV2.BlockReason != model.QuoteBlockReasonUnspecified {
|
||||
t.Fatalf("unexpected block_reason: %q", store.created.StatusV2.BlockReason)
|
||||
if store.created.Items[0].Status.State != model.QuoteStateExecutable {
|
||||
t.Fatalf("unexpected state: %q", store.created.Items[0].Status.State)
|
||||
}
|
||||
if store.created.Items[0].Status.BlockReason != model.QuoteBlockReasonUnspecified {
|
||||
t.Fatalf("unexpected block_reason: %q", store.created.Items[0].Status.BlockReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,21 +74,22 @@ func TestPersistBatch(t *testing.T) {
|
||||
IdempotencyKey: "idem-batch-1",
|
||||
Hash: "hash-batch-1",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "i1"},
|
||||
{Ref: "i2"},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
{QuoteRef: "q2"},
|
||||
},
|
||||
Statuses: []*StatusInput{
|
||||
RequestShape: model.QuoteRequestShapeBatch,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
Intent: &model.PaymentIntent{Ref: "i1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
},
|
||||
},
|
||||
{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
|
||||
Intent: &model.PaymentIntent{Ref: "i2"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -90,11 +99,14 @@ func TestPersistBatch(t *testing.T) {
|
||||
if record == nil {
|
||||
t.Fatalf("expected record")
|
||||
}
|
||||
if len(record.StatusesV2) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d", len(record.StatusesV2))
|
||||
if record.RequestShape != model.QuoteRequestShapeBatch {
|
||||
t.Fatalf("unexpected request shape: %q", record.RequestShape)
|
||||
}
|
||||
if record.StatusesV2[0].BlockReason != model.QuoteBlockReasonRouteUnavailable {
|
||||
t.Fatalf("unexpected first status block reason: %q", record.StatusesV2[0].BlockReason)
|
||||
if len(record.Items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(record.Items))
|
||||
}
|
||||
if record.Items[0].Status == nil || record.Items[0].Status.BlockReason != model.QuoteBlockReasonRouteUnavailable {
|
||||
t.Fatalf("unexpected first status block reason")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,11 +126,16 @@ func TestPersistValidation(t *testing.T) {
|
||||
IdempotencyKey: "i",
|
||||
Hash: "h",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intent: &model.PaymentIntent{Ref: "intent"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
@@ -131,16 +148,22 @@ func TestPersistValidation(t *testing.T) {
|
||||
IdempotencyKey: "i",
|
||||
Hash: "h",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "i1"},
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "i1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE},
|
||||
},
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "i2"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE},
|
||||
},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
},
|
||||
Statuses: []*StatusInput{},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for statuses mismatch, got %v", err)
|
||||
t.Fatalf("expected invalid argument for single shape with multiple items, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,22 +28,6 @@ func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make([]*model.QuoteStatusV2, 0, len(inputs))
|
||||
for i, item := range inputs {
|
||||
mapped, err := mapStatusInput(item)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("statuses[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
result = append(result, mapped)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mapQuoteState(state quotationv2.QuoteState) model.QuoteState {
|
||||
switch state {
|
||||
case quotationv2.QuoteState_QUOTE_STATE_INDICATIVE:
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
@@ -41,7 +42,7 @@ func TestMap_ExecutableQuote(t *testing.T) {
|
||||
},
|
||||
Route: "ationv2.RouteSpecification{
|
||||
Rail: "CARD",
|
||||
Provider: "monetix",
|
||||
Provider: paymenttypes.DefaultCardsGatewayID,
|
||||
PayoutMethod: "CARD",
|
||||
Settlement: "ationv2.RouteSettlement{
|
||||
Asset: &paymentv1.ChainAsset{
|
||||
@@ -100,7 +101,7 @@ func TestMap_ExecutableQuote(t *testing.T) {
|
||||
if got := out.Quote.GetPricedAt().AsTime(); !got.Equal(pricedAt) {
|
||||
t.Fatalf("unexpected priced_at: %v", got)
|
||||
}
|
||||
if got, want := out.Quote.GetRoute().GetProvider(), "monetix"; got != want {
|
||||
if got, want := out.Quote.GetRoute().GetProvider(), paymenttypes.DefaultCardsGatewayID; got != want {
|
||||
t.Fatalf("unexpected route provider: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
|
||||
|
||||
@@ -139,7 +139,6 @@ Converts plan/build errors to QuoteBlockReason.
|
||||
Produces quote state (executable/blocked/indicative + block_reason when blocked).
|
||||
QuotePersistenceService
|
||||
Persists quote record with v2 status metadata.
|
||||
Keeps legacy ExecutionNote for backward compatibility.
|
||||
QuoteResponseMapperV2
|
||||
Maps canonical quote + status to quotationv2.PaymentQuote.
|
||||
Enforces your lifecycle/execution invariants.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
@@ -133,6 +134,7 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
|
||||
Comment: strings.TrimSpace(in.Intent.GetComment()),
|
||||
SettlementMode: settlementMode,
|
||||
FeeTreatment: feeTreatment,
|
||||
FXSide: fxSideFromProto(in.Intent.GetFxSide()),
|
||||
SettlementCurrency: settlementCurrency,
|
||||
RequiresFX: requiresFX,
|
||||
Attributes: map[string]string{
|
||||
@@ -210,3 +212,14 @@ func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment {
|
||||
return QuoteFeeTreatmentUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
return paymenttypes.FXSideBuyBaseSellQuote
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
return paymenttypes.FXSideSellBaseBuyQuote
|
||||
default:
|
||||
return paymenttypes.FXSideUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ type QuoteIntent struct {
|
||||
Comment string
|
||||
SettlementMode QuoteSettlementMode
|
||||
FeeTreatment QuoteFeeTreatment
|
||||
FXSide paymenttypes.FXSide
|
||||
SettlementCurrency string
|
||||
RequiresFX bool
|
||||
Attributes map[string]string
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1"
|
||||
@@ -98,6 +100,51 @@ func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateOne_PropagatesFXSide(t *testing.T) {
|
||||
h := New(nil, WithRefFactory(func() string { return "q-intent-fx-side" }))
|
||||
intent := "ationv2.QuoteIntent{
|
||||
Source: &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||
PaymentMethod: &endpointv1.PaymentMethod{
|
||||
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
|
||||
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src-1"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
Destination: &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||
PaymentMethod: &endpointv1.PaymentMethod{
|
||||
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
|
||||
Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{
|
||||
Pan: "4111111111111111",
|
||||
ExpMonth: "12",
|
||||
ExpYear: "2030",
|
||||
Country: "US",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
Amount: newMoney("10", "USDT"),
|
||||
SettlementCurrency: "RUB",
|
||||
FxSide: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
}
|
||||
|
||||
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
|
||||
OrganizationRef: bson.NewObjectID().Hex(),
|
||||
InitiatorRef: bson.NewObjectID().Hex(),
|
||||
Intent: intent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HydrateOne returned error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected hydrated intent")
|
||||
}
|
||||
if got.FXSide != paymenttypes.FXSideBuyBaseSellQuote {
|
||||
t.Fatalf("unexpected fx side: got=%q", got.FXSide)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) {
|
||||
orgRef := bson.NewObjectID().Hex()
|
||||
methodRef := bson.NewObjectID().Hex()
|
||||
@@ -681,14 +728,6 @@ func newMoney(amount, currency string) *moneyv1.Money {
|
||||
}
|
||||
}
|
||||
|
||||
func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint {
|
||||
return &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{
|
||||
PaymentMethodRef: methodRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalBSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := bson.Marshal(value)
|
||||
|
||||
@@ -12,20 +12,13 @@ type PaymentQuoteRecord struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
|
||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||
Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"`
|
||||
Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"`
|
||||
Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"`
|
||||
Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"`
|
||||
StatusV2 *QuoteStatusV2 `bson:"statusV2,omitempty" json:"statusV2,omitempty"`
|
||||
StatusesV2 []*QuoteStatusV2 `bson:"statusesV2,omitempty" json:"statusesV2,omitempty"`
|
||||
Plan *PaymentPlan `bson:"plan,omitempty" json:"plan,omitempty"`
|
||||
Plans []*PaymentPlan `bson:"plans,omitempty" json:"plans,omitempty"`
|
||||
ExecutionNote string `bson:"executionNote,omitempty" json:"executionNote,omitempty"`
|
||||
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
|
||||
PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"`
|
||||
Hash string `bson:"hash" json:"hash"`
|
||||
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
|
||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||
RequestShape QuoteRequestShape `bson:"requestShape,omitempty" json:"requestShape,omitempty"`
|
||||
Items []*PaymentQuoteItemV2 `bson:"items,omitempty" json:"items,omitempty"`
|
||||
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
|
||||
PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"`
|
||||
Hash string `bson:"hash" json:"hash"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package model
|
||||
|
||||
// QuoteRequestShape identifies the API surface that created the quote record.
|
||||
type QuoteRequestShape string
|
||||
|
||||
const (
|
||||
QuoteRequestShapeUnspecified QuoteRequestShape = "unspecified"
|
||||
QuoteRequestShapeSingle QuoteRequestShape = "single"
|
||||
QuoteRequestShapeBatch QuoteRequestShape = "batch"
|
||||
)
|
||||
|
||||
// QuoteState captures v2 quote state metadata for persistence.
|
||||
type QuoteState string
|
||||
|
||||
@@ -30,3 +39,10 @@ type QuoteStatusV2 struct {
|
||||
State QuoteState `bson:"state,omitempty" json:"state,omitempty"`
|
||||
BlockReason QuoteBlockReason `bson:"blockReason,omitempty" json:"blockReason,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentQuoteItemV2 keeps one intent/quote/status tuple in a stable shape.
|
||||
type PaymentQuoteItemV2 struct {
|
||||
Intent *PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"`
|
||||
Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"`
|
||||
Status *QuoteStatusV2 `bson:"status,omitempty" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -90,25 +91,39 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er
|
||||
if quote.IdempotencyKey == "" {
|
||||
return merrors.InvalidArgument("quotesStore: idempotency key is required")
|
||||
}
|
||||
quote.ExecutionNote = strings.TrimSpace(quote.ExecutionNote)
|
||||
quote.RequestShape = model.QuoteRequestShape(strings.TrimSpace(string(quote.RequestShape)))
|
||||
if quote.RequestShape == "" || quote.RequestShape == model.QuoteRequestShapeUnspecified {
|
||||
return merrors.InvalidArgument("quotesStore: request shape is required")
|
||||
}
|
||||
if len(quote.Items) == 0 {
|
||||
return merrors.InvalidArgument("quotesStore: items are required")
|
||||
}
|
||||
if quote.RequestShape == model.QuoteRequestShapeSingle && len(quote.Items) != 1 {
|
||||
return merrors.InvalidArgument("quotesStore: single shape requires exactly one item")
|
||||
}
|
||||
if quote.ExpiresAt.IsZero() {
|
||||
return merrors.InvalidArgument("quotesStore: expires_at is required")
|
||||
}
|
||||
if quote.PurgeAt.IsZero() || quote.PurgeAt.Before(quote.ExpiresAt) {
|
||||
quote.PurgeAt = quote.ExpiresAt.Add(q.retention)
|
||||
}
|
||||
if quote.Intent.Attributes != nil {
|
||||
for k, v := range quote.Intent.Attributes {
|
||||
quote.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
for i := range quote.Items {
|
||||
item := quote.Items[i]
|
||||
if item == nil {
|
||||
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "] is required")
|
||||
}
|
||||
}
|
||||
if len(quote.Intents) > 0 {
|
||||
for i := range quote.Intents {
|
||||
if quote.Intents[i].Attributes == nil {
|
||||
continue
|
||||
}
|
||||
for k, v := range quote.Intents[i].Attributes {
|
||||
quote.Intents[i].Attributes[k] = strings.TrimSpace(v)
|
||||
if item.Intent == nil {
|
||||
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].intent is required")
|
||||
}
|
||||
if item.Quote == nil {
|
||||
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].quote is required")
|
||||
}
|
||||
if item.Status == nil {
|
||||
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].status is required")
|
||||
}
|
||||
if item.Intent.Attributes != nil {
|
||||
for k, v := range item.Intent.Attributes {
|
||||
item.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user