package gateway import ( "context" "encoding/json" "sync" "testing" "github.com/tech/sendico/gateway/tgsettle/storage" storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" envelope "github.com/tech/sendico/pkg/messaging/envelope" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" "github.com/tech/sendico/pkg/model" notification "github.com/tech/sendico/pkg/model/notification" "github.com/tech/sendico/pkg/mservice" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ) type fakePaymentsStore struct { mu sync.Mutex executions map[string]*storagemodel.PaymentExecution } func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentExecution, error) { f.mu.Lock() defer f.mu.Unlock() return f.executions[key], nil } func (f *fakePaymentsStore) InsertExecution(_ context.Context, exec *storagemodel.PaymentExecution) error { f.mu.Lock() defer f.mu.Unlock() if f.executions == nil { f.executions = map[string]*storagemodel.PaymentExecution{} } if _, ok := f.executions[exec.IdempotencyKey]; ok { return storage.ErrDuplicate } f.executions[exec.IdempotencyKey] = exec return nil } type fakeTelegramStore struct { mu sync.Mutex records map[string]*storagemodel.TelegramConfirmation } func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error { f.mu.Lock() defer f.mu.Unlock() if f.records == nil { f.records = map[string]*storagemodel.TelegramConfirmation{} } f.records[record.RequestID] = record return nil } type fakeRepo struct { payments *fakePaymentsStore tg *fakeTelegramStore } func (f *fakeRepo) Payments() storage.PaymentsStore { return f.payments } func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore { return f.tg } type captureProducer struct { mu sync.Mutex confirmationRequests []*model.ConfirmationRequest executions []*model.PaymentGatewayExecution } func (c *captureProducer) SendMessage(env envelope.Envelope) error { _, _ = env.Serialize() switch env.GetSignature().ToString() { case model.NewNotification(mservice.Notifications, notification.NAConfirmationRequest).ToString(): var req model.ConfirmationRequest if err := json.Unmarshal(env.GetData(), &req); err == nil { c.mu.Lock() c.confirmationRequests = append(c.confirmationRequests, &req) c.mu.Unlock() } case model.NewNotification(mservice.PaymentGateway, notification.NAPaymentGatewayExecution).ToString(): var exec model.PaymentGatewayExecution if err := json.Unmarshal(env.GetData(), &exec); err == nil { c.mu.Lock() c.executions = append(c.executions, &exec) c.mu.Unlock() } } return nil } func (c *captureProducer) Reset() { c.mu.Lock() defer c.mu.Unlock() c.confirmationRequests = nil c.executions = nil } func TestOnIntentCreatesConfirmationRequest(t *testing.T) { logger := mloggerfactory.NewLogger(false) repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} prod := &captureProducer{} t.Setenv("PGS_CHAT_ID", "-100") svc := NewService(logger, repo, prod, nil, Config{ Rail: "card", TargetChatIDEnv: "PGS_CHAT_ID", TimeoutSeconds: 90, AcceptedUserIDs: []string{"42"}, }) prod.Reset() intent := &model.PaymentGatewayIntent{ PaymentIntentID: "pi-1", IdempotencyKey: "idem-1", OutgoingLeg: "card", QuoteRef: "quote-1", RequestedMoney: &paymenttypes.Money{Amount: "10.50", Currency: "USD"}, TargetChatID: "", } if err := svc.onIntent(context.Background(), intent); err != nil { t.Fatalf("onIntent error: %v", err) } if len(prod.confirmationRequests) != 1 { t.Fatalf("expected 1 confirmation request, got %d", len(prod.confirmationRequests)) } req := prod.confirmationRequests[0] if req.RequestID != "idem-1" || req.PaymentIntentID != "pi-1" || req.QuoteRef != "quote-1" { t.Fatalf("unexpected confirmation request fields: %#v", req) } if req.TargetChatID != "-100" { t.Fatalf("expected target chat id -100, got %q", req.TargetChatID) } if req.RequestedMoney == nil || req.RequestedMoney.Amount != "10.50" || req.RequestedMoney.Currency != "USD" { t.Fatalf("requested money mismatch: %#v", req.RequestedMoney) } if req.TimeoutSeconds != 90 { t.Fatalf("expected timeout 90, got %d", req.TimeoutSeconds) } if req.SourceService != string(mservice.PaymentGateway) || req.Rail != "card" { t.Fatalf("unexpected source/rail: %#v", req) } } func TestIntentFromSubmitTransferUsesSourceMoney(t *testing.T) { req := &chainv1.SubmitTransferRequest{ IdempotencyKey: "idem-1", ClientReference: "pi-1", Amount: &moneyv1.Money{Amount: "10.00", Currency: "EUR"}, Metadata: map[string]string{ metadataSourceAmount: "12.34", metadataSourceCurrency: "USD", }, } intent, err := intentFromSubmitTransfer(req, "provider_settlement", "") if err != nil { t.Fatalf("intentFromSubmitTransfer error: %v", err) } if intent.RequestedMoney == nil || intent.RequestedMoney.Amount != "12.34" || intent.RequestedMoney.Currency != "USD" { t.Fatalf("expected source money override, got: %#v", intent.RequestedMoney) } } func TestConfirmationResultPersistsExecutionAndReply(t *testing.T) { logger := mloggerfactory.NewLogger(false) repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} prod := &captureProducer{} svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) intent := &model.PaymentGatewayIntent{ PaymentIntentID: "pi-2", IdempotencyKey: "idem-2", QuoteRef: "quote-2", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, } svc.trackIntent("idem-2", intent) result := &model.ConfirmationResult{ RequestID: "idem-2", Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, Status: model.ConfirmationStatusConfirmed, RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2", Text: "5 EUR"}, } if err := svc.onConfirmationResult(context.Background(), result); err != nil { t.Fatalf("onConfirmationResult error: %v", err) } if repo.payments.executions["idem-2"] == nil { t.Fatalf("expected payment execution to be stored") } if repo.payments.executions["idem-2"].ExecutedMoney == nil || repo.payments.executions["idem-2"].ExecutedMoney.Amount != "5" { t.Fatalf("executed money not stored correctly") } if repo.tg.records["idem-2"] == nil || repo.tg.records["idem-2"].RawReply.Text != "5 EUR" { t.Fatalf("telegram reply not stored correctly") } } func TestClarifiedResultPersistsExecution(t *testing.T) { logger := mloggerfactory.NewLogger(false) repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} prod := &captureProducer{} svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) intent := &model.PaymentGatewayIntent{ PaymentIntentID: "pi-clarified", IdempotencyKey: "idem-clarified", QuoteRef: "quote-clarified", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "12", Currency: "USD"}, } svc.trackIntent("idem-clarified", intent) result := &model.ConfirmationResult{ RequestID: "idem-clarified", Money: &paymenttypes.Money{Amount: "12", Currency: "USD"}, Status: model.ConfirmationStatusClarified, RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "3", Text: "12 USD"}, } if err := svc.onConfirmationResult(context.Background(), result); err != nil { t.Fatalf("onConfirmationResult error: %v", err) } if repo.payments.executions["idem-clarified"] == nil { t.Fatalf("expected payment execution to be stored") } } func TestIdempotencyPreventsDuplicateWrites(t *testing.T) { logger := mloggerfactory.NewLogger(false) repo := &fakeRepo{payments: &fakePaymentsStore{executions: map[string]*storagemodel.PaymentExecution{ "idem-3": {IdempotencyKey: "idem-3"}, }}, tg: &fakeTelegramStore{}} prod := &captureProducer{} svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) intent := &model.PaymentGatewayIntent{ PaymentIntentID: "pi-3", IdempotencyKey: "idem-3", OutgoingLeg: "card", QuoteRef: "quote-3", RequestedMoney: &paymenttypes.Money{Amount: "1", Currency: "USD"}, TargetChatID: "chat", } if err := svc.onIntent(context.Background(), intent); err != nil { t.Fatalf("onIntent error: %v", err) } if len(prod.confirmationRequests) != 0 { t.Fatalf("expected no confirmation request for duplicate intent") } } func TestTimeoutDoesNotPersistExecution(t *testing.T) { logger := mloggerfactory.NewLogger(false) repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} prod := &captureProducer{} svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) intent := &model.PaymentGatewayIntent{ PaymentIntentID: "pi-4", IdempotencyKey: "idem-4", QuoteRef: "quote-4", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "8", Currency: "USD"}, } svc.trackIntent("idem-4", intent) result := &model.ConfirmationResult{ RequestID: "idem-4", Status: model.ConfirmationStatusTimeout, } if err := svc.onConfirmationResult(context.Background(), result); err != nil { t.Fatalf("onConfirmationResult error: %v", err) } if repo.payments.executions["idem-4"] != nil { t.Fatalf("expected no execution record for timeout") } } func TestRejectedDoesNotPersistExecution(t *testing.T) { logger := mloggerfactory.NewLogger(false) repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} prod := &captureProducer{} svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) intent := &model.PaymentGatewayIntent{ PaymentIntentID: "pi-reject", IdempotencyKey: "idem-reject", QuoteRef: "quote-reject", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "3", Currency: "USD"}, } svc.trackIntent("idem-reject", intent) result := &model.ConfirmationResult{ RequestID: "idem-reject", Status: model.ConfirmationStatusRejected, RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "4", Text: "no"}, } if err := svc.onConfirmationResult(context.Background(), result); err != nil { t.Fatalf("onConfirmationResult error: %v", err) } if repo.payments.executions["idem-reject"] != nil { t.Fatalf("expected no execution record for rejection") } if repo.tg.records["idem-reject"] == nil { t.Fatalf("expected raw reply to be stored for rejection") } }