payment observation step for mntx

This commit is contained in:
Stephan D
2026-01-26 02:07:37 +01:00
parent 97448a2f15
commit 3c0993686d
9 changed files with 336 additions and 52 deletions

View File

@@ -196,6 +196,56 @@ func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
}
func updateCardPayoutPlanSteps(payment *model.Payment, payout *mntxv1.CardPayoutState) bool {
if payment == nil || payout == nil || payment.PaymentPlan == nil {
return false
}
plan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
if plan == nil {
return false
}
payoutID := strings.TrimSpace(payout.GetPayoutId())
if payoutID == "" {
return false
}
updated := false
for idx, planStep := range payment.PaymentPlan.Steps {
if planStep == nil {
continue
}
if planStep.Rail != model.RailCardPayout {
continue
}
if planStep.Action != model.RailOperationSend && planStep.Action != model.RailOperationObserveConfirm {
continue
}
if idx >= len(plan.Steps) {
continue
}
execStep := plan.Steps[idx]
if execStep == nil {
execStep = &model.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)}
plan.Steps[idx] = execStep
}
if execStep.TransferRef == "" {
execStep.TransferRef = payoutID
}
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
setExecutionStepStatus(execStep, executionStepStatusConfirmed)
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
setExecutionStepStatus(execStep, executionStepStatusFailed)
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
setExecutionStepStatus(execStep, executionStepStatusSubmitted)
default:
setExecutionStepStatus(execStep, executionStepStatusPlanned)
}
updated = true
}
return updated
}
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
if payment == nil || payout == nil {
return
@@ -209,8 +259,9 @@ func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutStat
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
}
updated := updateCardPayoutPlanSteps(payment, payout)
plan := ensureExecutionPlan(payment)
if plan != nil {
if plan != nil && !updated {
step := findExecutionStepByTransferRef(plan, strings.TrimSpace(payout.GetPayoutId()))
if step == nil {
step = ensureExecutionStep(plan, stepCodeCardPayout)

View File

@@ -124,11 +124,11 @@ func (e gatewayIneligibleError) Error() string {
return e.reason
}
func gatewayIneligible(reason string) error {
func gatewayIneligible(gw *model.GatewayInstanceDescriptor, reason string) error {
if strings.TrimSpace(reason) == "" {
reason = "gateway instance is not eligible"
}
return gatewayIneligibleError{reason: reason}
return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", gw.InstanceID, reason)}
}
func sendDirectionLabel(dir sendDirection) string {
@@ -144,16 +144,16 @@ func sendDirectionLabel(dir sendDirection) string {
func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) error {
if gw == nil {
return gatewayIneligible("gateway instance is required")
return gatewayIneligible(gw, "gateway instance is required")
}
if !gw.IsEnabled {
return gatewayIneligible("gateway instance is disabled")
return gatewayIneligible(gw, "gateway instance is disabled")
}
if gw.Rail != rail {
return gatewayIneligible(fmt.Sprintf("rail mismatch: want %s got %s", rail, gw.Rail))
return gatewayIneligible(gw, fmt.Sprintf("rail mismatch: want %s got %s", rail, gw.Rail))
}
if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) {
return gatewayIneligible(fmt.Sprintf("network mismatch: want %s got %s", network, gw.Network))
return gatewayIneligible(gw, fmt.Sprintf("network mismatch: want %s got %s", network, gw.Network))
}
if currency != "" && len(gw.Currencies) > 0 {
found := false
@@ -164,16 +164,16 @@ func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, net
}
}
if !found {
return gatewayIneligible("currency not supported: " + currency)
return gatewayIneligible(gw, "currency not supported: "+currency)
}
}
if !capabilityAllowsAction(gw.Capabilities, action, dir) {
return gatewayIneligible(fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir)))
return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir)))
}
if currency != "" {
if err := amountWithinLimits(gw.Limits, currency, amount, action); err != nil {
if err := amountWithinLimits(gw, gw.Limits, currency, amount, action); err != nil {
return err
}
}
@@ -204,7 +204,7 @@ func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperati
}
}
func amountWithinLimits(limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error {
func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error {
min := firstLimitValue(limits.MinAmount, "")
max := firstLimitValue(limits.MaxAmount, "")
perTxMin := firstLimitValue(limits.PerTxMinAmount, "")
@@ -221,27 +221,27 @@ func amountWithinLimits(limits model.Limits, currency string, amount decimal.Dec
if min != "" {
if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) {
return gatewayIneligible(fmt.Sprintf("amount %s %s below min limit %s", amount.String(), currency, val.String()))
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below min limit %s", amount.String(), currency, val.String()))
}
}
if perTxMin != "" {
if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) {
return gatewayIneligible(fmt.Sprintf("amount %s %s below per-tx min limit %s", amount.String(), currency, val.String()))
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below per-tx min limit %s", amount.String(), currency, val.String()))
}
}
if max != "" {
if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) {
return gatewayIneligible(fmt.Sprintf("amount %s %s exceeds max limit %s", amount.String(), currency, val.String()))
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds max limit %s", amount.String(), currency, val.String()))
}
}
if perTxMax != "" {
if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) {
return gatewayIneligible(fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String()))
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String()))
}
}
if action == model.RailOperationFee && maxFee != "" {
if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) {
return gatewayIneligible(fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String()))
return gatewayIneligible(gw, fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String()))
}
}

View File

@@ -0,0 +1,136 @@
//go:build integration
// +build integration
package orchestrator
import (
"context"
"strings"
"testing"
"time"
storagemongo "github.com/tech/sendico/payments/orchestrator/storage/mongo"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/db/repository"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
"go.uber.org/zap"
)
func terminateMongo(ctx context.Context, t *testing.T, container *mongodb.MongoDBContainer) {
t.Helper()
if err := container.Terminate(ctx); err != nil {
t.Fatalf("failed to terminate MongoDB container: %v", err)
}
}
func disconnectMongo(ctx context.Context, t *testing.T, client *mongo.Client) {
t.Helper()
if err := client.Disconnect(ctx); err != nil {
t.Fatalf("failed to disconnect from MongoDB: %v", err)
}
}
func TestQuotePayment_IdempotencyReuseAfterExpiry(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
if err != nil {
t.Fatalf("failed to start MongoDB container: %v", err)
}
defer terminateMongo(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
if err != nil {
t.Fatalf("failed to get MongoDB connection string: %v", err)
}
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
if err != nil {
t.Fatalf("failed to connect to MongoDB: %v", err)
}
defer disconnectMongo(ctx, t, client)
db := client.Database("test_" + strings.ReplaceAll(t.Name(), "/", "_"))
paymentsRepo := repository.CreateMongoRepository(db, (&model.Payment{}).Collection())
quotesRepo := repository.CreateMongoRepository(db, (&model.PaymentQuoteRecord{}).Collection())
routesRepo := repository.CreateMongoRepository(db, (&model.PaymentRoute{}).Collection())
plansRepo := repository.CreateMongoRepository(db, (&model.PaymentPlanTemplate{}).Collection())
ping := func(ctx context.Context) error { return client.Ping(ctx, readpref.Primary()) }
store, err := storagemongo.NewWithRepository(zap.NewNop(), ping, paymentsRepo, quotesRepo, routesRepo, plansRepo)
if err != nil {
t.Fatalf("failed to create payments repository: %v", err)
}
now := time.Now()
svc := NewService(zap.NewNop(), store, WithClock(testClock{now: now}))
orgID := primitive.NewObjectID()
req := &orchestratorv1.QuotePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: orgID.Hex()},
IdempotencyKey: "idem-expired-quote",
Intent: &orchestratorv1.PaymentIntent{
Source: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{
Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"},
},
},
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{
Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"},
},
},
Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"},
SettlementCurrency: "USDT",
},
}
resp1, err := svc.QuotePayment(ctx, req)
if err != nil {
t.Fatalf("first quote returned error: %v", err)
}
firstRef := resp1.GetQuote().GetQuoteRef()
if firstRef == "" {
t.Fatal("expected first quote ref to be populated")
}
quotesColl := db.Collection((&model.PaymentQuoteRecord{}).Collection())
update := bson.M{"$set": bson.M{"expiresAt": time.Now().Add(-time.Minute)}}
result, err := quotesColl.UpdateOne(ctx, bson.M{
"organizationRef": orgID,
"idempotencyKey": req.GetIdempotencyKey(),
}, update)
if err != nil {
t.Fatalf("failed to expire quote: %v", err)
}
if result.MatchedCount == 0 {
t.Fatal("expected expired quote to be updated")
}
resp2, err := svc.QuotePayment(ctx, req)
if err != nil {
t.Fatalf("second quote returned error: %v", err)
}
secondRef := resp2.GetQuote().GetQuoteRef()
if secondRef == "" {
t.Fatal("expected second quote ref to be populated")
}
if secondRef == firstRef {
t.Fatal("expected a new quote to be generated after expiry")
}
}