outbox for gateways

This commit is contained in:
Stephan D
2026-02-18 01:35:28 +01:00
parent 974caf286c
commit 69531cee73
221 changed files with 12172 additions and 782 deletions

View File

@@ -4,7 +4,10 @@ go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
replace github.com/tech/sendico/gateway/common => ../common
require (
github.com/tech/sendico/gateway/common v0.1.0
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1
@@ -45,5 +48,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
)

View File

@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -90,13 +90,18 @@ func (i *Imp) Start() error {
if cfg.GRPC != nil {
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
}
msgSettings := map[string]any(nil)
if cfg.Messaging != nil {
msgSettings = cfg.Messaging.Settings
}
gwCfg := gateway.Config{
Rail: cfg.Gateway.Rail,
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
TimeoutSeconds: cfg.Gateway.TimeoutSeconds,
AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs,
SuccessReaction: cfg.Gateway.SuccessReaction,
InvokeURI: invokeURI,
Rail: cfg.Gateway.Rail,
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
TimeoutSeconds: cfg.Gateway.TimeoutSeconds,
AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs,
SuccessReaction: cfg.Gateway.SuccessReaction,
InvokeURI: invokeURI,
MessagingSettings: msgSettings,
}
svc := gateway.NewService(logger, repo, producer, broker, gwCfg)
i.service = svc

View File

@@ -0,0 +1,47 @@
package gateway
import (
"context"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/pkg/db/transaction"
me "github.com/tech/sendico/pkg/messaging/envelope"
)
type tgOutboxProvider interface {
Outbox() gatewayoutbox.Store
}
type tgTransactionProvider interface {
TransactionFactory() transaction.Factory
}
func (s *Service) outboxStore() gatewayoutbox.Store {
provider, ok := s.repo.(tgOutboxProvider)
if !ok || provider == nil {
return nil
}
return provider.Outbox()
}
func (s *Service) startOutboxReliableProducer() error {
if s == nil || s.repo == nil {
return nil
}
return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg)
}
func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error {
if err := s.startOutboxReliableProducer(); err != nil {
return err
}
return s.outbox.Send(ctx, env)
}
func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
provider, ok := s.repo.(tgTransactionProvider)
if !ok || provider == nil || provider.TransactionFactory() == nil {
return cb(ctx)
}
return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb)
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/api/routers"
@@ -20,6 +21,7 @@ import (
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
@@ -48,12 +50,13 @@ const (
)
type Config struct {
Rail string
TargetChatIDEnv string
TimeoutSeconds int32
AcceptedUserIDs []string
SuccessReaction string
InvokeURI string
Rail string
TargetChatIDEnv string
TimeoutSeconds int32
AcceptedUserIDs []string
SuccessReaction string
InvokeURI string
MessagingSettings pmodel.SettingsT
}
type Service struct {
@@ -62,11 +65,13 @@ type Service struct {
producer msg.Producer
broker mb.Broker
cfg Config
msgCfg pmodel.SettingsT
rail string
chatID string
announcer *discovery.Announcer
invokeURI string
successReaction string
outbox gatewayoutbox.ReliableRuntime
consumers []msg.Consumer
@@ -84,6 +89,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
producer: producer,
broker: broker,
cfg: cfg,
msgCfg: cfg.MessagingSettings,
rail: strings.TrimSpace(cfg.Rail),
invokeURI: strings.TrimSpace(cfg.InvokeURI),
}
@@ -92,6 +98,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
if svc.successReaction == "" {
svc.successReaction = defaultTelegramSuccessReaction
}
if err := svc.startOutboxReliableProducer(); err != nil {
svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err))
}
svc.startConsumers()
svc.startAnnouncer()
return svc
@@ -107,6 +116,7 @@ func (s *Service) Shutdown() {
if s == nil {
return
}
s.outbox.Stop()
if s.announcer != nil {
s.announcer.Stop()
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
@@ -21,33 +22,57 @@ func isFinalStatus(t *model.PaymentRecord) bool {
}
}
func toOpStatus(t *model.PaymentRecord) rail.OperationResult {
func toOpStatus(t *model.PaymentRecord) (rail.OperationResult, error) {
switch t.Status {
case model.PaymentStatusFailed:
return rail.OperationResultFailed
return rail.OperationResultFailed, nil
case model.PaymentStatusSuccess:
return rail.OperationResultSuccess
return rail.OperationResultSuccess, nil
case model.PaymentStatusCancelled:
return rail.OperationResultCancelled
return rail.OperationResultCancelled, nil
default:
panic("unexpected transfer status")
return rail.OperationResultFailed, merrors.InvalidArgument("unexpected transfer status", "payment.status")
}
}
func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error {
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
if !isFinalStatus(record) {
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
return err
}
return nil
}
_, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
if upsertErr := s.repo.Payments().Upsert(txCtx, record); upsertErr != nil {
return nil, upsertErr
}
if isFinalStatus(record) {
if emitErr := s.emitTransferStatusEvent(txCtx, record); emitErr != nil {
return nil, emitErr
}
}
return nil, nil
})
if err != nil {
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
return err
}
if isFinalStatus(record) {
s.emitTransferStatusEvent(ctx, record)
}
return nil
}
func (s *Service) emitTransferStatusEvent(_ context.Context, record *model.PaymentRecord) {
if s == nil || s.producer == nil || record == nil {
return
func (s *Service) emitTransferStatusEvent(ctx context.Context, record *model.PaymentRecord) error {
if s == nil || record == nil {
return nil
}
if s.producer == nil || s.outboxStore() == nil {
return nil
}
status, err := toOpStatus(record)
if err != nil {
s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID))
return err
}
exec := pmodel.PaymentGatewayExecution{
@@ -55,13 +80,15 @@ func (s *Service) emitTransferStatusEvent(_ context.Context, record *model.Payme
IdempotencyKey: record.IdempotencyKey,
ExecutedMoney: record.ExecutedMoney,
PaymentRef: record.PaymentRef,
Status: toOpStatus(record),
Status: status,
OperationRef: record.OperationRef,
Error: record.FailureReason,
TransferRef: record.ID.Hex(),
}
env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID))
if sendErr := s.sendWithOutbox(ctx, env); sendErr != nil {
s.logger.Warn("Failed to publish transfer status event", zap.Error(sendErr), mzap.ObjRef("transfer_ref", record.ID))
return sendErr
}
return nil
}

View File

@@ -4,9 +4,11 @@ import (
"context"
"time"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/gateway/tgsettle/storage"
"github.com/tech/sendico/gateway/tgsettle/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/mongo"
@@ -14,12 +16,14 @@ import (
)
type Repository struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
txFactory transaction.Factory
payments storage.PaymentsStore
tg storage.TelegramConfirmationsStore
outbox gatewayoutbox.Store
}
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
@@ -43,9 +47,10 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
logger = logger.With(zap.String("database", dbName))
}
result := &Repository{
logger: logger,
conn: conn,
db: db,
logger: logger,
conn: conn,
db: db,
txFactory: newMongoTransactionFactory(client),
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -63,8 +68,14 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations"))
return nil, err
}
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
return nil, err
}
result.payments = paymentsStore
result.tg = tgStore
result.outbox = outboxStore
result.logger.Info("Payment gateway MongoDB storage initialised")
return result, nil
}
@@ -77,4 +88,12 @@ func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore
return r.tg
}
func (r *Repository) Outbox() gatewayoutbox.Store {
return r.outbox
}
func (r *Repository) TransactionFactory() transaction.Factory {
return r.txFactory
}
var _ storage.Repository = (*Repository)(nil)

View File

@@ -0,0 +1,38 @@
package mongo
import (
"context"
"github.com/tech/sendico/pkg/db/transaction"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type mongoTransactionFactory struct {
client *mongo.Client
}
func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction {
return &mongoTransaction{client: f.client}
}
type mongoTransaction struct {
client *mongo.Client
}
func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
session, err := t.client.StartSession()
if err != nil {
return nil, err
}
defer session.EndSession(ctx)
run := func(sessCtx context.Context) (any, error) {
return cb(sessCtx)
}
return session.WithTransaction(ctx, run)
}
func newMongoTransactionFactory(client *mongo.Client) transaction.Factory {
return &mongoTransactionFactory{client: client}
}