TG settlement service

This commit is contained in:
Stephan D
2026-01-02 14:54:18 +01:00
parent ea1c69f14a
commit 743f683d92
82 changed files with 4693 additions and 503 deletions

View File

@@ -0,0 +1,334 @@
package gateway
import (
"context"
"os"
"strings"
"sync"
"github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
)
const (
defaultConfirmationTimeoutSeconds = 120
executedStatus = "executed"
)
type Config struct {
Rail string
TargetChatIDEnv string
TimeoutSeconds int32
AcceptedUserIDs []string
}
type Service struct {
logger mlogger.Logger
repo storage.Repository
producer msg.Producer
broker mb.Broker
cfg Config
rail string
chatID string
announcer *discovery.Announcer
mu sync.Mutex
pending map[string]*model.PaymentGatewayIntent
consumers []msg.Consumer
}
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service {
if logger != nil {
logger = logger.Named("tgsettle_gateway")
}
svc := &Service{
logger: logger,
repo: repo,
producer: producer,
broker: broker,
cfg: cfg,
rail: strings.TrimSpace(cfg.Rail),
pending: map[string]*model.PaymentGatewayIntent{},
}
svc.chatID = strings.TrimSpace(readEnv(cfg.TargetChatIDEnv))
svc.startConsumers()
svc.startAnnouncer()
return svc
}
func (s *Service) Register(_ routers.GRPC) error {
return nil
}
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
for _, consumer := range s.consumers {
if consumer != nil {
consumer.Close()
}
}
}
func (s *Service) startConsumers() {
if s == nil || s.broker == nil {
if s != nil && s.logger != nil {
s.logger.Warn("Messaging broker not configured; confirmation flow disabled")
}
return
}
intentProcessor := paymentgateway.NewPaymentGatewayIntentProcessor(s.logger, s.onIntent)
s.consumeProcessor(intentProcessor)
resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult)
s.consumeProcessor(resultProcessor)
}
func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) {
consumer, err := cons.NewConsumer(s.logger, s.broker, processor.GetSubject())
if err != nil {
s.logger.Warn("Failed to create messaging consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
return
}
s.consumers = append(s.consumers, consumer)
go func() {
if err := consumer.ConsumeMessages(processor.Process); err != nil {
s.logger.Warn("Messaging consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
}
}()
}
func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayIntent) error {
if intent == nil {
return merrors.InvalidArgument("payment gateway intent is nil", "intent")
}
intent = normalizeIntent(intent)
if intent.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotency_key is required", "idempotency_key")
}
if intent.PaymentIntentID == "" {
return merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id")
}
if intent.RequestedMoney == nil || strings.TrimSpace(intent.RequestedMoney.Amount) == "" || strings.TrimSpace(intent.RequestedMoney.Currency) == "" {
return merrors.InvalidArgument("requested_money is required", "requested_money")
}
if s.repo == nil || s.repo.Payments() == nil {
return merrors.Internal("payment gateway storage unavailable")
}
existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, intent.IdempotencyKey)
if err != nil {
return err
}
if existing != nil {
s.logger.Info("Payment gateway intent already executed", zap.String("idempotency_key", intent.IdempotencyKey))
return nil
}
confirmReq, err := s.buildConfirmationRequest(intent)
if err != nil {
return err
}
if err := s.sendConfirmationRequest(confirmReq); err != nil {
return err
}
s.trackIntent(confirmReq.RequestID, intent)
return nil
}
func (s *Service) onConfirmationResult(ctx context.Context, result *model.ConfirmationResult) error {
if result == nil {
return merrors.InvalidArgument("confirmation result is nil", "result")
}
requestID := strings.TrimSpace(result.RequestID)
if requestID == "" {
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
}
intent := s.lookupIntent(requestID)
if intent == nil {
s.logger.Warn("Confirmation result ignored: intent not found", zap.String("request_id", requestID))
return nil
}
if result.RawReply != nil && s.repo != nil && s.repo.TelegramConfirmations() != nil {
_ = s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{
RequestID: requestID,
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
RawReply: result.RawReply,
})
}
if result.Status == model.ConfirmationStatusConfirmed || result.Status == model.ConfirmationStatusClarified {
exec := &storagemodel.PaymentExecution{
IdempotencyKey: intent.IdempotencyKey,
PaymentIntentID: intent.PaymentIntentID,
ExecutedMoney: result.Money,
QuoteRef: intent.QuoteRef,
Status: executedStatus,
}
if err := s.repo.Payments().InsertExecution(ctx, exec); err != nil && err != storage.ErrDuplicate {
return err
}
}
s.publishExecution(intent, result)
s.removeIntent(requestID)
return nil
}
func (s *Service) buildConfirmationRequest(intent *model.PaymentGatewayIntent) (*model.ConfirmationRequest, error) {
targetChatID := strings.TrimSpace(intent.TargetChatID)
if targetChatID == "" {
targetChatID = s.chatID
}
if targetChatID == "" {
return nil, merrors.InvalidArgument("target_chat_id is required", "target_chat_id")
}
rail := strings.TrimSpace(intent.OutgoingLeg)
if rail == "" {
rail = s.rail
}
timeout := s.cfg.TimeoutSeconds
if timeout <= 0 {
timeout = int32(defaultConfirmationTimeoutSeconds)
}
return &model.ConfirmationRequest{
RequestID: intent.IdempotencyKey,
TargetChatID: targetChatID,
RequestedMoney: intent.RequestedMoney,
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
AcceptedUserIDs: s.cfg.AcceptedUserIDs,
TimeoutSeconds: timeout,
SourceService: string(mservice.PaymentGateway),
Rail: rail,
}, nil
}
func (s *Service) sendConfirmationRequest(request *model.ConfirmationRequest) error {
if request == nil {
return merrors.InvalidArgument("confirmation request is nil", "request")
}
if s.producer == nil {
return merrors.Internal("messaging producer is not configured")
}
env := confirmations.ConfirmationRequest(string(mservice.PaymentGateway), request)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("request_id", request.RequestID))
return err
}
return nil
}
func (s *Service) publishExecution(intent *model.PaymentGatewayIntent, result *model.ConfirmationResult) {
if s == nil || intent == nil || result == nil || s.producer == nil {
return
}
exec := &model.PaymentGatewayExecution{
PaymentIntentID: intent.PaymentIntentID,
IdempotencyKey: intent.IdempotencyKey,
QuoteRef: intent.QuoteRef,
ExecutedMoney: result.Money,
Status: result.Status,
RequestID: result.RequestID,
RawReply: result.RawReply,
}
env := paymentgateway.PaymentGatewayExecution(string(mservice.PaymentGateway), exec)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish gateway execution result", zap.Error(err), zap.String("request_id", result.RequestID))
}
}
func (s *Service) trackIntent(requestID string, intent *model.PaymentGatewayIntent) {
if s == nil || intent == nil {
return
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return
}
s.mu.Lock()
s.pending[requestID] = intent
s.mu.Unlock()
}
func (s *Service) lookupIntent(requestID string) *model.PaymentGatewayIntent {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
return s.pending[requestID]
}
func (s *Service) removeIntent(requestID string) {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return
}
s.mu.Lock()
delete(s.pending, requestID)
s.mu.Unlock()
}
func (s *Service) startAnnouncer() {
if s == nil || s.producer == nil {
return
}
caps := []string{"telegram_confirmation", "money_persistence"}
if s.rail != "" {
caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail))
}
announce := discovery.Announcement{
Service: string(mservice.PaymentGateway),
Rail: s.rail,
Operations: caps,
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.PaymentGateway), announce)
s.announcer.Start()
}
func normalizeIntent(intent *model.PaymentGatewayIntent) *model.PaymentGatewayIntent {
if intent == nil {
return nil
}
cp := *intent
cp.PaymentIntentID = strings.TrimSpace(cp.PaymentIntentID)
cp.IdempotencyKey = strings.TrimSpace(cp.IdempotencyKey)
cp.OutgoingLeg = strings.TrimSpace(cp.OutgoingLeg)
cp.QuoteRef = strings.TrimSpace(cp.QuoteRef)
cp.TargetChatID = strings.TrimSpace(cp.TargetChatID)
if cp.RequestedMoney != nil {
cp.RequestedMoney.Amount = strings.TrimSpace(cp.RequestedMoney.Amount)
cp.RequestedMoney.Currency = strings.TrimSpace(cp.RequestedMoney.Currency)
}
return &cp
}
func readEnv(env string) string {
if strings.TrimSpace(env) == "" {
return ""
}
return strings.TrimSpace(os.Getenv(env))
}
var _ grpcapp.Service = (*Service)(nil)

View File

@@ -0,0 +1,289 @@
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"
"github.com/tech/sendico/pkg/model"
notification "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
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 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")
}
}