+source currency pick fix +fx side propagation

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

View File

@@ -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.

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,13 +35,14 @@ func TestResolve_Expired(t *testing.T) {
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
},
Quote: &model.PaymentQuoteSnapshot{},
StatusV2: &model.QuoteStatusV2{
State: model.QuoteStateExecutable,
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(-time.Second),
}, nil
@@ -62,14 +63,17 @@ func TestResolve_NotExecutableState(t *testing.T) {
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
},
Quote: &model.PaymentQuoteSnapshot{},
StatusV2: &model.QuoteStatusV2{
State: model.QuoteStateBlocked,
BlockReason: model.QuoteBlockReasonRouteUnavailable,
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{
State: model.QuoteStateBlocked,
BlockReason: model.QuoteBlockReasonRouteUnavailable,
},
},
},
ExpiresAt: now.Add(time.Minute),
}, nil
@@ -83,20 +87,23 @@ func TestResolve_NotExecutableState(t *testing.T) {
}
}
func TestResolve_NotExecutableExecutionNote(t *testing.T) {
func TestResolve_NotExecutableIndicative(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateIndicative},
},
},
Quote: &model.PaymentQuoteSnapshot{},
ExecutionNote: "quote will not be executed",
ExpiresAt: now.Add(time.Minute),
ExpiresAt: now.Add(time.Minute),
}, nil
},
}, Input{
@@ -115,13 +122,19 @@ func TestResolve_ShapeMismatch(t *testing.T) {
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intents: []model.PaymentIntent{
{Kind: model.PaymentKindPayout},
{Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{},
QuoteRef: "quote-ref",
RequestShape: model.QuoteRequestShapeSingle,
Items: []*model.PaymentQuoteItemV2{
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
{
Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout},
Quote: &model.PaymentQuoteSnapshot{},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
},
},
ExpiresAt: now.Add(time.Minute),
}, nil

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
package quotation
import paymenttypes "github.com/tech/sendico/pkg/payments/types"
const (
defaultCardGateway = "monetix"
defaultCardGateway = paymenttypes.DefaultCardsGatewayID
)

View File

@@ -12,7 +12,7 @@ import (
)
const (
defaultCardGatewayKey = "monetix"
defaultCardGatewayKey = paymenttypes.DefaultCardsGatewayID
)
type CardGatewayFundingRoute struct {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)),
)

View File

@@ -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))

View File

@@ -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: &quotationv2.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)
}

View File

@@ -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 := &quotationv2.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 := &quote_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

View File

@@ -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,
},
}
}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 ""
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}
}

View File

@@ -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},
},
},
}
}

View File

@@ -13,19 +13,18 @@ type StatusInput struct {
BlockReason quotationv2.QuoteBlockReason
}
type PersistItemInput struct {
Intent *model.PaymentIntent
Quote *model.PaymentQuoteSnapshot
Status *StatusInput
}
type PersistInput struct {
OrganizationID bson.ObjectID
QuoteRef string
IdempotencyKey string
Hash string
ExpiresAt time.Time
Intent *model.PaymentIntent
Intents []model.PaymentIntent
Quote *model.PaymentQuoteSnapshot
Quotes []*model.PaymentQuoteSnapshot
Status *StatusInput
Statuses []*StatusInput
RequestShape model.QuoteRequestShape
Items []PersistItemInput
}

View File

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

View File

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

View File

@@ -28,22 +28,6 @@ func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
}, nil
}
func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) {
if len(inputs) == 0 {
return nil, nil
}
result := make([]*model.QuoteStatusV2, 0, len(inputs))
for i, item := range inputs {
mapped, err := mapStatusInput(item)
if err != nil {
return nil, merrors.InvalidArgument("statuses[" + itoa(i) + "]: " + err.Error())
}
result = append(result, mapped)
}
return result, nil
}
func mapQuoteState(state quotationv2.QuoteState) model.QuoteState {
switch state {
case quotationv2.QuoteState_QUOTE_STATE_INDICATIVE:

View File

@@ -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: &quotationv2.RouteSpecification{
Rail: "CARD",
Provider: "monetix",
Provider: paymenttypes.DefaultCardsGatewayID,
PayoutMethod: "CARD",
Settlement: &quotationv2.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 {

View File

@@ -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.

View File

@@ -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
}
}

View File

@@ -84,6 +84,7 @@ type QuoteIntent struct {
Comment string
SettlementMode QuoteSettlementMode
FeeTreatment QuoteFeeTreatment
FXSide paymenttypes.FXSide
SettlementCurrency string
RequiresFX bool
Attributes map[string]string

View File

@@ -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 := &quotationv2.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)

View File

@@ -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.

View File

@@ -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"`
}

View File

@@ -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)
}
}
}