payment observation step for mntx
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user