diff --git a/api/gateway/mntx/config.dev.yml b/api/gateway/mntx/config.dev.yml index a9b5261c..0bc18a93 100644 --- a/api/gateway/mntx/config.dev.yml +++ b/api/gateway/mntx/config.dev.yml @@ -35,7 +35,7 @@ messaging: reconnect_wait: 5 buffer_size: 1024 -monetix: +mcards: base_url_env: MONETIX_BASE_URL project_id_env: MONETIX_PROJECT_ID secret_key_env: MONETIX_SECRET_KEY @@ -46,7 +46,7 @@ monetix: status_processing: "processing" gateway: - id: "monetix" + id: "mcards" is_enabled: true network: "MIR" currencies: ["RUB"] diff --git a/api/gateway/mntx/config.yml b/api/gateway/mntx/config.yml index 48d98d51..31bf0022 100644 --- a/api/gateway/mntx/config.yml +++ b/api/gateway/mntx/config.yml @@ -35,7 +35,7 @@ messaging: reconnect_wait: 5 buffer_size: 1024 -monetix: +mcards: base_url_env: MONETIX_BASE_URL project_id_env: MONETIX_PROJECT_ID secret_key_env: MONETIX_SECRET_KEY @@ -46,7 +46,7 @@ monetix: status_processing: "processing" gateway: - id: "monetix" + id: "mcards" is_enabled: true network: "MIR" currencies: ["RUB"] diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index 70f133d9..796cd24a 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -22,6 +22,7 @@ import ( "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + paymenttypes "github.com/tech/sendico/pkg/payments/types" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" "github.com/tech/sendico/pkg/server/grpcapp" "go.uber.org/zap" @@ -41,7 +42,7 @@ type Imp struct { type config struct { *grpcapp.Config `yaml:",inline"` - Monetix monetixConfig `yaml:"monetix"` + Monetix monetixConfig `yaml:"mcards"` Gateway gatewayConfig `yaml:"gateway"` HTTP httpConfig `yaml:"http"` } @@ -216,7 +217,7 @@ func (i *Imp) Start() error { return gatewaymongo.New(logger, conn) } - app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, repoFactory, serviceFactory) + app, err := grpcapp.NewApp(i.logger, paymenttypes.DefaultCardsGatewayID, cfg.Config, i.debug, repoFactory, serviceFactory) if err != nil { return err } @@ -275,7 +276,7 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) { if id, err := strconv.ParseInt(raw, 10, 64); err == nil { projectID = id } else { - return monetix.Config{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "monetix.project_id") + return monetix.Config{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "mcards.project_id") } } } @@ -310,7 +311,7 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) { func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gatewayv1.GatewayInstanceDescriptor { id := strings.TrimSpace(cfg.ID) if id == "" { - id = "monetix" + id = paymenttypes.DefaultCardsGatewayID } network := strings.ToUpper(strings.TrimSpace(cfg.Network)) @@ -444,7 +445,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, } path := strings.TrimSpace(cfg.Path) if path == "" { - path = "/monetix/callback" + path = "/" + paymenttypes.DefaultCardsGatewayID + "/callback" } maxBody := cfg.MaxBodyBytes if maxBody <= 0 { diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index a134e217..dbf10cc1 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -78,7 +78,7 @@ func (p *cardPayoutProcessor) resolveProjectID(requestProjectID int64, logFieldK } if projectID == 0 { p.logger.Warn("Monetix project_id is not configured", zap.String(logFieldKey, logFieldValue)) - return 0, merrors.Internal("monetix project_id is not configured") + return 0, merrors.Internal("mcards project_id is not configured") } return projectID, nil } diff --git a/api/payments/orchestrator/config.dev.yml b/api/payments/orchestrator/config.dev.yml index e75ea97e..310f97e5 100644 --- a/api/payments/orchestrator/config.dev.yml +++ b/api/payments/orchestrator/config.dev.yml @@ -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. diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index e7fb1e14..0f5e3f07 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -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. diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go index cf06e307..2402ee11 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go @@ -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. diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go index 2011cab0..38d16240 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go @@ -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 diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go index 7045157e..fd551e90 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go @@ -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{ diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go index 8fd9bab7..d67b68f1 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go @@ -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", diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go index c06f6956..8e316323 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go @@ -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 { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index 0b25dfd9..b3291681 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -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", diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go index fe5530ee..a272543d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go @@ -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 { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go index a56147b2..25cbdcd3 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index afe1e0af..dd0c555e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -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 diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 1ea46f17..82a3f38f 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -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), } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go index 5fda64db..4f03aff1 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -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 diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index 8afa5cf6..a6b65fcb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -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 diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index 91c56a2c..6adbf5bc 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -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) -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go index 2ff07d69..0def2253 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go @@ -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 "": diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go index 72949bd8..7e17b58e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go @@ -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() diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go index 985229a5..ecb5453d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go @@ -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) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index 7546ce6c..f8b2d431 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -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, diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go index 87e82f15..8bfce504 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go @@ -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", diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go index a3721439..49c18bb2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go @@ -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}, }, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go index 28e8a8cd..eca5fb22 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go @@ -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) } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go index 35c2d280..555c36c0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go @@ -228,8 +228,8 @@ func testLiquidityProbePayment( { Index: 2, Rail: "CARD", - Gateway: "monetix", - InstanceID: "monetix", + Gateway: paymenttypes.DefaultCardsGatewayID, + InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go index 37dc7aa4..6eed5339 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -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) diff --git a/api/payments/quotation/internal/service/quotation/card_payout_constants.go b/api/payments/quotation/internal/service/quotation/card_payout_constants.go index 50cc7fab..fa895a39 100644 --- a/api/payments/quotation/internal/service/quotation/card_payout_constants.go +++ b/api/payments/quotation/internal/service/quotation/card_payout_constants.go @@ -1,5 +1,7 @@ package quotation +import paymenttypes "github.com/tech/sendico/pkg/payments/types" + const ( - defaultCardGateway = "monetix" + defaultCardGateway = paymenttypes.DefaultCardsGatewayID ) diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go index 485280f6..07155172 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go @@ -12,7 +12,7 @@ import ( ) const ( - defaultCardGatewayKey = "monetix" + defaultCardGatewayKey = paymenttypes.DefaultCardsGatewayID ) type CardGatewayFundingRoute struct { diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go index bdafd3b6..c3e4fd36 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go @@ -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) diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 0f6cbabc..486094bc 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -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 { diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers_test.go b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go index 90b030e7..3b892ac1 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers_test.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go @@ -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) + } +} diff --git a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go index fefd1f9a..736b9d77 100644 --- a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go +++ b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go @@ -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 diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go index 7663a37a..e6ee0f0a 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go @@ -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)), ) diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go index 29fa44d0..b70d63b0 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go @@ -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)) diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go index 29a83569..47312c1d 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go @@ -13,28 +13,34 @@ func (s *QuotationServiceV2) singleResultFromRecord(record *model.PaymentQuoteRe if record == nil { return nil, merrors.InvalidArgument("record is required") } - if record.Quote == nil { + if record.RequestShape != model.QuoteRequestShapeSingle { + return nil, merrors.InvalidArgument("record request shape is not single") + } + if len(record.Items) != 1 || record.Items[0] == nil { + return nil, merrors.InvalidArgument("record single item is required") + } + item := record.Items[0] + if item.Intent == nil { + return nil, merrors.InvalidArgument("record intent is required") + } + if item.Quote == nil { return nil, merrors.InvalidArgument("record quote is required") } - status := statusFromStored(record.StatusV2) + status := statusFromStored(item.Status) mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{ Meta: quote_response_mapper_v2.QuoteMeta{ ID: record.GetID().Hex(), CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, }, - Quote: canonicalFromSnapshot(record.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), + Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), Status: status, }) if err != nil { return nil, err } - intentRef := strings.TrimSpace(record.Intent.Ref) - if len(record.Intents) == 1 { - intentRef = firstNonEmpty(strings.TrimSpace(record.Intents[0].Ref), intentRef) - } - mapped.Quote.IntentRef = intentRef + mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref) return &QuotePaymentResult{ Response: "ationv2.QuotePaymentResponse{ Quote: mapped.Quote, @@ -48,17 +54,22 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec if record == nil { return nil, merrors.InvalidArgument("record is required") } - if len(record.Quotes) == 0 { - return nil, merrors.InvalidArgument("record quotes are required") + if record.RequestShape != model.QuoteRequestShapeBatch { + return nil, merrors.InvalidArgument("record request shape is not batch") + } + if len(record.Items) == 0 { + return nil, merrors.InvalidArgument("record items are required") } - quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Quotes)) - for idx, snapshot := range record.Quotes { - var storedStatus *model.QuoteStatusV2 - if idx < len(record.StatusesV2) { - storedStatus = record.StatusesV2[idx] + quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Items)) + for _, item := range record.Items { + if item == nil { + return nil, merrors.InvalidArgument("record item is required") } - status := statusFromStored(storedStatus) + if item.Quote == nil { + return nil, merrors.InvalidArgument("record item quote is required") + } + status := statusFromStored(item.Status) mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{ Meta: quote_response_mapper_v2.QuoteMeta{ @@ -66,14 +77,14 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, }, - Quote: canonicalFromSnapshot(snapshot, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), + Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), Status: status, }) if err != nil { return nil, err } - if idx < len(record.Intents) { - mapped.Quote.IntentRef = strings.TrimSpace(record.Intents[idx].Ref) + if item.Intent != nil { + mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref) } quotes = append(quotes, mapped.Quote) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go index b8e6a494..3158bf26 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -14,6 +14,7 @@ import ( quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/merrors" pkgmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" @@ -188,6 +189,131 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { t.Logf("single response:\n%s", mustProtoJSON(t, result.Response)) } +func TestQuotePayment_FixReceivedRUB_ProducesUSDTDebit_EndToEnd(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + orgID := bson.NewObjectID() + + store := newInMemoryQuotesStore() + core := &fakeQuoteCore{now: now} + svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), + QuotesStore: store, + Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { + return "q-intent-fix-received" + })), + Computation: quote_computation_service.New( + core, + quote_computation_service.WithManagedWalletNetworkResolver(staticManagedWalletResolverForE2E{ + assetsByRef: map[string]*paymenttypes.Asset{ + "wallet-usdt-source": { + Chain: "TRON_NILE", + TokenSymbol: "USDT", + }, + }, + }), + ), + Now: func() time.Time { return now }, + NewRef: func() string { return "quote-fix-received-rub" }, + }) + + intent := makeTransferIntent(t, "5000", "RUB", "wallet-usdt-source", "4111111111111111", "RU") + intent.SettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + intent.SettlementCurrency = "RUB" + + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: orgID.Hex(), + }, + IdempotencyKey: "idem-fix-received-rub", + InitiatorRef: "initiator-42", + PreviewOnly: false, + Intent: intent, + } + + result, err := svc.ProcessQuotePayment(context.Background(), req) + if err != nil { + t.Fatalf("ProcessQuotePayment returned error: %v", err) + } + if result == nil || result.Response == nil || result.Response.GetQuote() == nil { + t.Fatalf("expected quote response") + } + quote := result.Response.GetQuote() + + rate := decimal.RequireFromString("91.5") + received := decimal.RequireFromString("5000") + expectedPrincipal := received.Div(rate) + + feeTotal := decimal.Zero + for _, line := range quote.GetFeeLines() { + if line == nil || line.GetMoney() == nil { + continue + } + if !strings.EqualFold(line.GetMoney().GetCurrency(), "USDT") { + continue + } + lineAmount := decimal.RequireFromString(line.GetMoney().GetAmount()) + switch line.GetSide() { + case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: + feeTotal = feeTotal.Sub(lineAmount) + default: + feeTotal = feeTotal.Add(lineAmount) + } + } + expectedTotalDebit := expectedPrincipal.Add(feeTotal) + + if got, want := quote.GetTransferPrincipalAmount().GetAmount(), expectedPrincipal.String(); got != want { + t.Fatalf("unexpected principal amount: got=%q want=%q", got, want) + } + if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected principal currency: got=%q want=%q", got, want) + } + if got, want := quote.GetDestinationAmount().GetAmount(), "5000"; got != want { + t.Fatalf("unexpected destination amount: got=%q want=%q", got, want) + } + if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want { + t.Fatalf("unexpected destination currency: got=%q want=%q", got, want) + } + if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), expectedTotalDebit.String(); got != want { + t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want) + } + if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected payer_total_debit_amount currency: got=%q want=%q", got, want) + } + if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String()) + } + if quote.GetRoute() == nil || quote.GetRoute().GetSettlement() == nil { + t.Fatalf("expected route settlement") + } + if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_received"; got != want { + t.Fatalf("unexpected route settlement model: got=%q want=%q", got, want) + } + if quote.GetFxQuote() == nil { + t.Fatalf("expected fx quote") + } + if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want { + t.Fatalf("unexpected fx base: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want { + t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want { + t.Fatalf("unexpected fx side: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetFxQuote().GetQuoteAmount().GetAmount(), "5000"; got != want { + t.Fatalf("unexpected fx quote amount: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetQuoteAmount().GetCurrency(), "RUB"; got != want { + t.Fatalf("unexpected fx quote amount currency: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetBaseAmount().GetAmount(), expectedPrincipal.String(); got != want { + t.Fatalf("unexpected fx base amount: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetBaseAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected fx base amount currency: got=%q want=%q", got, want) + } +} + func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) { now := time.Unix(1_700_000_000, 0).UTC() orgID := bson.NewObjectID() @@ -605,29 +731,70 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi return nil, time.Time{}, fmt.Errorf("route hops are required for route-bound quote pricing") } - baseAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount()) + intentAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount()) + amountCurrency := strings.ToUpper(strings.TrimSpace(in.Intent.Amount.GetCurrency())) rate := decimal.RequireFromString("91.5") - quoteAmount := baseAmount.Mul(rate) + + baseCurrency := "USDT" + quoteCurrency := "RUB" + fxSide := fxv1.Side_SELL_BASE_BUY_QUOTE + if in.Intent.FX != nil && in.Intent.FX.Pair != nil { + if base := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetBase())); base != "" { + baseCurrency = base + } + if quote := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetQuote())); quote != "" { + quoteCurrency = quote + } + switch in.Intent.FX.Side { + case paymenttypes.FXSideBuyBaseSellQuote: + fxSide = fxv1.Side_BUY_BASE_SELL_QUOTE + default: + fxSide = fxv1.Side_SELL_BASE_BUY_QUOTE + } + } + + baseAmount := intentAmount + quoteAmount := intentAmount.Mul(rate) + switch { + case strings.EqualFold(amountCurrency, quoteCurrency): + quoteAmount = intentAmount + baseAmount = intentAmount.Div(rate) + case strings.EqualFold(amountCurrency, baseCurrency): + baseAmount = intentAmount + quoteAmount = intentAmount.Mul(rate) + } + + payAmount := baseAmount + payCurrency := baseCurrency + settlementAmount := quoteAmount + settlementCurrency := quoteCurrency + if fxSide == fxv1.Side_BUY_BASE_SELL_QUOTE { + payAmount = quoteAmount + payCurrency = quoteCurrency + settlementAmount = baseAmount + settlementCurrency = baseCurrency + } + feeAmount := decimal.RequireFromString("1.50") taxAmount := decimal.RequireFromString("0.30") - if routeFeeClass(in.Route) != "card_payout:3_hops:monetix" { + if routeFeeClass(in.Route) != "card_payout:3_hops:"+paymenttypes.DefaultCardsGatewayID { feeAmount = decimal.RequireFromString("2.00") taxAmount = decimal.RequireFromString("0.40") } quote := "e_computation_service.ComputedQuote{ DebitAmount: &moneyv1.Money{ - Amount: baseAmount.String(), - Currency: "USDT", + Amount: payAmount.String(), + Currency: payCurrency, }, CreditAmount: &moneyv1.Money{ - Amount: quoteAmount.String(), - Currency: "RUB", + Amount: settlementAmount.String(), + Currency: settlementCurrency, }, FeeLines: []*feesv1.DerivedPostingLine{ { LedgerAccountRef: "ledger:fees:usdt", - Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: "USDT"}, + Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: payCurrency}, LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, Meta: map[string]string{ @@ -637,7 +804,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi }, { LedgerAccountRef: "ledger:tax:usdt", - Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: "USDT"}, + Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: payCurrency}, LineType: accountingv1.PostingLineType_POSTING_LINE_TAX, Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, Meta: map[string]string{ @@ -664,13 +831,13 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi FXQuote: &oraclev1.Quote{ QuoteRef: "fx-usdt-rub", Pair: &fxv1.CurrencyPair{ - Base: "USDT", - Quote: "RUB", + Base: baseCurrency, + Quote: quoteCurrency, }, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + Side: fxSide, Price: &moneyv1.Decimal{Value: rate.String()}, - BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"}, - QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: "RUB"}, + BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: baseCurrency}, + QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: quoteCurrency}, ExpiresAtUnixMs: f.now.Add(f.fxTTLValue()).UnixMilli(), Provider: "test-oracle", RateRef: "rate-usdt-rub", @@ -815,6 +982,30 @@ type staticGatewayRegistryForE2E struct { items []*model.GatewayInstanceDescriptor } +type staticManagedWalletResolverForE2E struct { + assetsByRef map[string]*paymenttypes.Asset +} + +func (r staticManagedWalletResolverForE2E) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) { + if len(r.assetsByRef) == 0 { + return nil, nil + } + asset, ok := r.assetsByRef[strings.TrimSpace(managedWalletRef)] + if !ok || asset == nil { + return nil, nil + } + cloned := *asset + return &cloned, nil +} + +func (r staticManagedWalletResolverForE2E) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) { + asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef) + if err != nil || asset == nil { + return "", err + } + return strings.TrimSpace(asset.GetChain()), nil +} + func (r staticGatewayRegistryForE2E) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { if len(r.items) == 0 { return nil, nil diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go index 129f59a8..33a65313 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -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, }, } } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go index 9c4cc6dd..213ccd41 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go @@ -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, diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters_test.go new file mode 100644 index 00000000..16524937 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters_test.go @@ -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) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go index fcd9ccb0..6d0a1719 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go @@ -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) diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go index 7b5981c8..4fad726d 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go @@ -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 +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index 82e313d9..21043734 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -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 { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go index 9b7b490e..d7c3803a 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go @@ -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 "" +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps_test.go new file mode 100644 index 00000000..d91b50d6 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps_test.go @@ -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) + } + }) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go index 1b0215a0..11e558d8 100644 --- a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go @@ -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 } } diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go index 73b8ffe0..f63956e9 100644 --- a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go @@ -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}, + }, + }, + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go index cc2da92b..8ade4f8d 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go index fe0465b6..96d2adfd 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go index 053623af..82ad2536 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go @@ -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) } } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go index 6bd1b14e..ab8320f7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go @@ -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: diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index 2a62a7a2..24fcc1fa 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -41,7 +42,7 @@ func TestMap_ExecutableQuote(t *testing.T) { }, Route: "ationv2.RouteSpecification{ Rail: "CARD", - Provider: "monetix", + Provider: paymenttypes.DefaultCardsGatewayID, PayoutMethod: "CARD", Settlement: "ationv2.RouteSettlement{ Asset: &paymentv1.ChainAsset{ @@ -100,7 +101,7 @@ func TestMap_ExecutableQuote(t *testing.T) { if got := out.Quote.GetPricedAt().AsTime(); !got.Equal(pricedAt) { t.Fatalf("unexpected priced_at: %v", got) } - if got, want := out.Quote.GetRoute().GetProvider(), "monetix"; got != want { + if got, want := out.Quote.GetRoute().GetProvider(), paymenttypes.DefaultCardsGatewayID; got != want { t.Fatalf("unexpected route provider: got=%q want=%q", got, want) } if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want { diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go index d14be17e..f6ab7930 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go @@ -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. diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index e115a766..02cc48db 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -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 + } +} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go index 419b2034..84dd3657 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go @@ -84,6 +84,7 @@ type QuoteIntent struct { Comment string SettlementMode QuoteSettlementMode FeeTreatment QuoteFeeTreatment + FXSide paymenttypes.FXSide SettlementCurrency string RequiresFX bool Attributes map[string]string diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go index 4b4a0d60..baa51757 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go @@ -7,6 +7,8 @@ import ( "testing" pkgmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1" @@ -98,6 +100,51 @@ func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) { } } +func TestHydrateOne_PropagatesFXSide(t *testing.T) { + h := New(nil, WithRefFactory(func() string { return "q-intent-fx-side" })) + intent := "ationv2.QuoteIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src-1"}), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + Country: "US", + }), + }, + }, + }, + Amount: newMoney("10", "USDT"), + SettlementCurrency: "RUB", + FxSide: fxv1.Side_BUY_BASE_SELL_QUOTE, + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("HydrateOne returned error: %v", err) + } + if got == nil { + t.Fatalf("expected hydrated intent") + } + if got.FXSide != paymenttypes.FXSideBuyBaseSellQuote { + t.Fatalf("unexpected fx side: got=%q", got.FXSide) + } +} + func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) { orgRef := bson.NewObjectID().Hex() methodRef := bson.NewObjectID().Hex() @@ -681,14 +728,6 @@ func newMoney(amount, currency string) *moneyv1.Money { } } -func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint { - return &endpointv1.PaymentEndpoint{ - Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{ - PaymentMethodRef: methodRef, - }, - } -} - func mustMarshalBSON(t *testing.T, value any) []byte { t.Helper() data, err := bson.Marshal(value) diff --git a/api/payments/storage/model/quote.go b/api/payments/storage/model/quote.go index 31cd901f..2f460414 100644 --- a/api/payments/storage/model/quote.go +++ b/api/payments/storage/model/quote.go @@ -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. diff --git a/api/payments/storage/model/quote_v2.go b/api/payments/storage/model/quote_v2.go index 87cdeeb8..8ee33490 100644 --- a/api/payments/storage/model/quote_v2.go +++ b/api/payments/storage/model/quote_v2.go @@ -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"` +} diff --git a/api/payments/storage/quote/mongo/store/quotes.go b/api/payments/storage/quote/mongo/store/quotes.go index d780fd2f..48ffcab9 100644 --- a/api/payments/storage/quote/mongo/store/quotes.go +++ b/api/payments/storage/quote/mongo/store/quotes.go @@ -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) } } } diff --git a/api/pkg/payments/types/gateway_ids.go b/api/pkg/payments/types/gateway_ids.go new file mode 100644 index 00000000..f11b1a79 --- /dev/null +++ b/api/pkg/payments/types/gateway_ids.go @@ -0,0 +1,6 @@ +package types + +// Well-known gateway identifiers used across payment routing. +const ( + DefaultCardsGatewayID = "mcards" +) diff --git a/api/proto/payments/orchestration/v2/orchestration.proto b/api/proto/payments/orchestration/v2/orchestration.proto index d47fb312..48c4a808 100644 --- a/api/proto/payments/orchestration/v2/orchestration.proto +++ b/api/proto/payments/orchestration/v2/orchestration.proto @@ -152,6 +152,10 @@ message StepExecution { Failure failure = 7; // External references produced by the step. repeated ExternalReference refs = 8; + // Reporting visibility for user/backoffice/audit projections. + ReportVisibility report_visibility = 9; + // Optional user-facing operation label. + string user_label = 10; } // Kept local on purpose: no shared enum exists for orchestration step runtime. @@ -172,6 +176,20 @@ enum StepExecutionState { STEP_EXECUTION_STATE_SKIPPED = 6; } +// ReportVisibility determines which audience should see the step. +enum ReportVisibility { + // Default zero value. + REPORT_VISIBILITY_UNSPECIFIED = 0; + // Hidden from all external reports. + REPORT_VISIBILITY_HIDDEN = 1; + // Visible to end users. + REPORT_VISIBILITY_USER = 2; + // Visible to backoffice operators. + REPORT_VISIBILITY_BACKOFFICE = 3; + // Visible only for audit/compliance review. + REPORT_VISIBILITY_AUDIT = 4; +} + // Failure describes a normalized step failure. message Failure { // Broad, shared failure category. diff --git a/api/proto/payments/quotation/v2/quotation.proto b/api/proto/payments/quotation/v2/quotation.proto index b5427f87..ccce05eb 100644 --- a/api/proto/payments/quotation/v2/quotation.proto +++ b/api/proto/payments/quotation/v2/quotation.proto @@ -6,6 +6,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v2;quo import "api/proto/payments/shared/v1/shared.proto"; import "api/proto/common/money/v1/money.proto"; +import "api/proto/common/fx/v1/fx.proto"; import "api/proto/common/payment/v1/settlement.proto"; import "api/proto/payments/endpoint/v1/endpoint.proto"; import "api/proto/payments/quotation/v2/interface.proto"; @@ -19,6 +20,7 @@ message QuoteIntent { payments.quotation.v2.FeeTreatment fee_treatment = 5; string settlement_currency = 6; string comment = 7; + common.fx.v1.Side fx_side = 8; } // QuotePaymentRequest is the request to quote a single v2 payment. diff --git a/api/server/interface/api/sresponse/money.go b/api/server/interface/api/sresponse/money.go index 93291d12..4e751a7b 100644 --- a/api/server/interface/api/sresponse/money.go +++ b/api/server/interface/api/sresponse/money.go @@ -14,19 +14,3 @@ func toMoney(m *moneyv1.Money) *paymenttypes.Money { Currency: m.GetCurrency(), } } - -func toMoneyList(list []*moneyv1.Money) []*paymenttypes.Money { - if len(list) == 0 { - return nil - } - result := make([]*paymenttypes.Money, 0, len(list)) - for _, item := range list { - if m := toMoney(item); m != nil { - result = append(result, m) - } - } - if len(result) == 0 { - return nil - } - return result -} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 31a35a8a..1aa4c4a7 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -64,14 +64,26 @@ type PaymentQuotes struct { } type Payment struct { - PaymentRef string `json:"paymentRef,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - State string `json:"state,omitempty"` - FailureCode string `json:"failureCode,omitempty"` - FailureReason string `json:"failureReason,omitempty"` - LastQuote *PaymentQuote `json:"lastQuote,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - Meta map[string]string `json:"meta,omitempty"` + PaymentRef string `json:"paymentRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + State string `json:"state,omitempty"` + FailureCode string `json:"failureCode,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + Operations []PaymentOperation `json:"operations,omitempty"` + LastQuote *PaymentQuote `json:"lastQuote,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +type PaymentOperation struct { + StepRef string `json:"stepRef,omitempty"` + Code string `json:"code,omitempty"` + State string `json:"state,omitempty"` + Label string `json:"label,omitempty"` + FailureCode string `json:"failureCode,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + StartedAt time.Time `json:"startedAt,omitempty"` + CompletedAt time.Time `json:"completedAt,omitempty"` } type paymentQuoteResponse struct { @@ -269,12 +281,14 @@ func toPayment(p *orchestrationv2.Payment) *Payment { if p == nil { return nil } - failureCode, failureReason := firstFailure(p.GetStepExecutions()) + operations := toUserVisibleOperations(p.GetStepExecutions()) + failureCode, failureReason := firstFailure(operations) return &Payment{ PaymentRef: p.GetPaymentRef(), State: enumJSONName(p.GetState().String()), FailureCode: failureCode, FailureReason: failureReason, + Operations: operations, LastQuote: toPaymentQuote(p.GetQuoteSnapshot()), CreatedAt: timestampAsTime(p.GetCreatedAt()), Meta: paymentMeta(p), @@ -282,21 +296,65 @@ func toPayment(p *orchestrationv2.Payment) *Payment { } } -func firstFailure(steps []*orchestrationv2.StepExecution) (string, string) { - for _, step := range steps { - if step == nil || step.GetFailure() == nil { +func firstFailure(operations []PaymentOperation) (string, string) { + for _, op := range operations { + if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" { continue } - failure := step.GetFailure() - message := strings.TrimSpace(failure.GetMessage()) - if message == "" { - message = strings.TrimSpace(failure.GetCode()) - } - return enumJSONName(failure.GetCategory().String()), message + return strings.TrimSpace(op.FailureCode), strings.TrimSpace(op.FailureReason) } return "", "" } +func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation { + if len(steps) == 0 { + return nil + } + ops := make([]PaymentOperation, 0, len(steps)) + for _, step := range steps { + if step == nil || !isUserVisibleStep(step.GetReportVisibility()) { + continue + } + ops = append(ops, toPaymentOperation(step)) + } + if len(ops) == 0 { + return nil + } + return ops +} + +func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation { + op := PaymentOperation{ + StepRef: step.GetStepRef(), + Code: step.GetStepCode(), + State: enumJSONName(step.GetState().String()), + Label: strings.TrimSpace(step.GetUserLabel()), + StartedAt: timestampAsTime(step.GetStartedAt()), + CompletedAt: timestampAsTime(step.GetCompletedAt()), + } + failure := step.GetFailure() + if failure == nil { + return op + } + op.FailureCode = enumJSONName(failure.GetCategory().String()) + op.FailureReason = strings.TrimSpace(failure.GetMessage()) + if op.FailureReason == "" { + op.FailureReason = strings.TrimSpace(failure.GetCode()) + } + return op +} + +func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool { + switch visibility { + case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN, + orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE, + orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT: + return false + default: + return true + } +} + func paymentMeta(p *orchestrationv2.Payment) map[string]string { if p == nil { return nil diff --git a/api/server/interface/api/sresponse/payment_test.go b/api/server/interface/api/sresponse/payment_test.go new file mode 100644 index 00000000..12883537 --- /dev/null +++ b/api/server/interface/api/sresponse/payment_test.go @@ -0,0 +1,119 @@ +package sresponse + +import ( + "testing" + + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) { + steps := []*orchestrationv2.StepExecution{ + { + StepRef: "hidden", + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN, + }, + { + StepRef: "user", + StepCode: "hop.4.card_payout.send", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER, + }, + { + StepRef: "unspecified", + StepCode: "hop.4.card_payout.observe", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED, + }, + { + StepRef: "backoffice", + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE, + }, + } + + ops := toUserVisibleOperations(steps) + if len(ops) != 2 { + t.Fatalf("operations count mismatch: got=%d want=2", len(ops)) + } + if got, want := ops[0].StepRef, "user"; got != want { + t.Fatalf("first operation step_ref mismatch: got=%q want=%q", got, want) + } + if got, want := ops[1].StepRef, "unspecified"; got != want { + t.Fatalf("second operation step_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestToPaymentFailureUsesVisibleOperationsOnly(t *testing.T) { + dto := toPayment(&orchestrationv2.Payment{ + PaymentRef: "pay-1", + State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, + StepExecutions: []*orchestrationv2.StepExecution{ + { + StepRef: "hidden_failed", + StepCode: "edge.1_2.ledger.debit", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN, + Failure: &orchestrationv2.Failure{ + Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER, + Message: "internal hold release failure", + }, + }, + { + StepRef: "user_failed", + StepCode: "hop.4.card_payout.send", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER, + Failure: &orchestrationv2.Failure{ + Category: sharedv1.PaymentFailureCode_FAILURE_CHAIN, + Message: "card declined", + }, + }, + }, + }) + if dto == nil { + t.Fatal("expected non-nil payment dto") + } + if got, want := dto.FailureCode, "failure_chain"; got != want { + t.Fatalf("failure_code mismatch: got=%q want=%q", got, want) + } + if got, want := dto.FailureReason, "card declined"; got != want { + t.Fatalf("failure_reason mismatch: got=%q want=%q", got, want) + } + if len(dto.Operations) != 1 { + t.Fatalf("operations count mismatch: got=%d want=1", len(dto.Operations)) + } + if got, want := dto.Operations[0].StepRef, "user_failed"; got != want { + t.Fatalf("visible operation mismatch: got=%q want=%q", got, want) + } +} + +func TestToPaymentIgnoresHiddenFailures(t *testing.T) { + dto := toPayment(&orchestrationv2.Payment{ + PaymentRef: "pay-2", + State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, + StepExecutions: []*orchestrationv2.StepExecution{ + { + StepRef: "hidden_failed", + StepCode: "edge.1_2.ledger.release", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE, + Failure: &orchestrationv2.Failure{ + Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER, + Message: "backoffice only failure", + }, + }, + }, + }) + if dto == nil { + t.Fatal("expected non-nil payment dto") + } + if got := dto.FailureCode; got != "" { + t.Fatalf("expected empty failure_code, got=%q", got) + } + if got := dto.FailureReason; got != "" { + t.Fatalf("expected empty failure_reason, got=%q", got) + } + if len(dto.Operations) != 0 { + t.Fatalf("expected no visible operations, got=%d", len(dto.Operations)) + } +} diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index cf66b04e..a2e76f37 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -8,6 +8,7 @@ import ( pkgmodel "github.com/tech/sendico/pkg/model" 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" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" @@ -58,6 +59,7 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e SettlementMode: resolvedSettlementMode, FeeTreatment: resolvedFeeTreatment, SettlementCurrency: settlementCurrency, + FxSide: mapFXSide(intent), } if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { quoteIntent.Comment = comment @@ -65,6 +67,20 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e return quoteIntent, nil } +func mapFXSide(intent *srequest.PaymentIntent) fxv1.Side { + if intent == nil || intent.FX == nil { + return fxv1.Side_SIDE_UNSPECIFIED + } + switch strings.TrimSpace(string(intent.FX.Side)) { + case string(srequest.FXSideBuyBaseSellQuote): + return fxv1.Side_BUY_BASE_SELL_QUOTE + case string(srequest.FXSideSellBaseBuyQuote): + return fxv1.Side_SELL_BASE_BUY_QUOTE + default: + return fxv1.Side_SIDE_UNSPECIFIED + } +} + func validatePaymentKind(kind srequest.PaymentKind) error { switch strings.TrimSpace(string(kind)) { case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion): diff --git a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go index 89f8f349..b1d65c8f 100644 --- a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go +++ b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go @@ -4,6 +4,7 @@ import ( "testing" 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" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" @@ -201,4 +202,54 @@ func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) { if got.GetSettlementCurrency() != "RUB" { t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) } + if got.GetFxSide() != fxv1.Side_SELL_BASE_BUY_QUOTE { + t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String()) + } +} + +func TestMapQuoteIntent_PropagatesFXSideBuyBaseSellQuote(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, + FX: &srequest.FXIntent{ + Pair: &srequest.CurrencyPair{ + Base: "RUB", + Quote: "USDT", + }, + Side: srequest.FXSideBuyBaseSellQuote, + }, + } + + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.GetFxSide() != fxv1.Side_BUY_BASE_SELL_QUOTE { + t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String()) + } + if got.GetSettlementCurrency() != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) + } } diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart index 7327ddef..7a21d973 100644 --- a/frontend/pshared/lib/data/dto/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -37,7 +37,6 @@ class PaymentIntentDTO { this.feeTreatment, }); - factory PaymentIntentDTO.fromJson(Map json) => - _$PaymentIntentDTOFromJson(json); + factory PaymentIntentDTO.fromJson(Map json) => _$PaymentIntentDTOFromJson(json); Map toJson() => _$PaymentIntentDTOToJson(this); } diff --git a/frontend/pshared/lib/data/mapper/payment/intent/fx.dart b/frontend/pshared/lib/data/mapper/payment/intent/fx.dart index fbafbc25..cd36a201 100644 --- a/frontend/pshared/lib/data/mapper/payment/intent/fx.dart +++ b/frontend/pshared/lib/data/mapper/payment/intent/fx.dart @@ -6,22 +6,22 @@ import 'package:pshared/models/payment/fx/intent.dart'; extension FxIntentMapper on FxIntent { FxIntentDTO toDTO() => FxIntentDTO( - pair: pair?.toDTO(), - side: fxSideToValue(side), - firm: firm, - ttlMs: ttlMs, - preferredProvider: preferredProvider, - maxAgeMs: maxAgeMs, - ); + pair: pair?.toDTO(), + side: fxSideToValue(side), + firm: firm, + ttlMs: ttlMs, + preferredProvider: preferredProvider, + maxAgeMs: maxAgeMs, + ); } extension FxIntentDTOMapper on FxIntentDTO { FxIntent toDomain() => FxIntent( - pair: pair?.toDomain(), - side: fxSideFromValue(side), - firm: firm, - ttlMs: ttlMs, - preferredProvider: preferredProvider, - maxAgeMs: maxAgeMs, - ); + pair: pair?.toDomain(), + side: fxSideFromValue(side), + firm: firm, + ttlMs: ttlMs, + preferredProvider: preferredProvider, + maxAgeMs: maxAgeMs, + ); } diff --git a/frontend/pshared/pubspec.yaml b/frontend/pshared/pubspec.yaml index bcb81b4c..836c0ab1 100644 --- a/frontend/pshared/pubspec.yaml +++ b/frontend/pshared/pubspec.yaml @@ -8,7 +8,7 @@ environment: # Add regular dependencies here. dependencies: analyzer: ^10.0.0 - json_annotation: ^4.10.0 + json_annotation: ^4.11.0 http: ^1.1.0 provider: ^6.0.5 flutter: diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 7c4154b1..9d6ce40e 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -7,7 +7,9 @@ import 'package:pshared/api/requests/payment/initiate_payments.dart'; import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/data/dto/money.dart'; +import 'package:pshared/data/dto/payment/currency_pair.dart'; import 'package:pshared/data/dto/payment/endpoint.dart'; +import 'package:pshared/data/dto/payment/intent/fx.dart'; import 'package:pshared/data/dto/payment/intent/payment.dart'; import 'package:pshared/data/mapper/payment/payment.dart'; import 'package:pshared/models/payment/asset.dart'; @@ -76,6 +78,36 @@ void main() { expect(destination['type'], equals('cardToken')); }); + test('quote payment request serializes fx side to backend value', () { + final request = QuotePaymentRequest( + idempotencyKey: '', + previewOnly: true, + intent: const PaymentIntentDTO( + kind: 'payout', + source: PaymentEndpointDTO( + type: 'managedWallet', + data: {'managed_wallet_ref': 'mw-1'}, + ), + destination: PaymentEndpointDTO( + type: 'cardToken', + data: {'token': 'tok_1', 'masked_pan': '4111'}, + ), + amount: MoneyDTO(amount: '10', currency: 'USDT'), + settlementMode: 'fix_source', + fx: FxIntentDTO( + pair: CurrencyPairDTO(base: 'RUB', quote: 'USDT'), + side: 'buy_base_sell_quote', + ), + ), + ); + + final json = + jsonDecode(jsonEncode(request.toJson())) as Map; + final intent = json['intent'] as Map; + final fx = intent['fx'] as Map; + expect(fx['side'], equals('buy_base_sell_quote')); + }); + test('quote response parses backend fx quote pricedAtUnixMs', () { final response = PaymentQuoteResponse.fromJson({ 'accessToken': {'token': 'token', 'expiration': '2026-02-25T00:00:00Z'}, diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index a190ace0..a14f021a 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -52,7 +52,7 @@ dependencies: share_plus: ^12.0.1 collection: ^1.18.0 flutter_timezone: ^5.0.1 - json_annotation: ^4.10.0 + json_annotation: ^4.11.0 go_router: ^17.0.0 jovial_svg: ^1.1.23 cached_network_image: ^3.4.1