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,10 +4,13 @@ go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
replace github.com/tech/sendico/gateway/common => ../common
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
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
@@ -48,5 +51,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

@@ -210,8 +210,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

@@ -191,14 +191,18 @@ func (i *Imp) Start() error {
if cfg.GRPC != nil {
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
}
svc := mntxservice.NewService(logger,
opts := []mntxservice.Option{
mntxservice.WithDiscoveryInvokeURI(invokeURI),
mntxservice.WithProducer(producer),
mntxservice.WithMonetixConfig(monetixCfg),
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
mntxservice.WithStorage(repo),
)
}
if cfg.Messaging != nil {
opts = append(opts, mntxservice.WithMessagingSettings(cfg.Messaging.Settings))
}
svc := mntxservice.NewService(logger, opts...)
i.service = svc
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/shopspring/decimal"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/model"
@@ -17,6 +18,7 @@ import (
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -30,6 +32,8 @@ type cardPayoutProcessor struct {
store storage.Repository
httpClient *http.Client
producer msg.Producer
msgCfg pmodel.SettingsT
outbox *gatewayoutbox.ReliableRuntime
perTxMinAmountMinor int64
perTxMinAmountMinorByCurrency map[string]int64

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
pmodel "github.com/tech/sendico/pkg/model"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
)
@@ -67,3 +68,12 @@ func WithDiscoveryInvokeURI(invokeURI string) Option {
s.invokeURI = strings.TrimSpace(invokeURI)
}
}
// WithMessagingSettings applies messaging driver settings.
func WithMessagingSettings(settings pmodel.SettingsT) Option {
return func(s *Service) {
if settings != nil {
s.msgCfg = settings
}
}
}

View File

@@ -0,0 +1,50 @@
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 mntxOutboxProvider interface {
Outbox() gatewayoutbox.Store
}
type mntxTransactionProvider interface {
TransactionFactory() transaction.Factory
}
func (p *cardPayoutProcessor) outboxStore() gatewayoutbox.Store {
provider, ok := p.store.(mntxOutboxProvider)
if !ok || provider == nil {
return nil
}
return provider.Outbox()
}
func (p *cardPayoutProcessor) startOutboxReliableProducer() error {
if p == nil || p.outbox == nil {
return nil
}
return p.outbox.Start(p.logger, p.producer, p.outboxStore(), p.msgCfg)
}
func (p *cardPayoutProcessor) sendWithOutbox(ctx context.Context, env me.Envelope) error {
if err := p.startOutboxReliableProducer(); err != nil {
return err
}
if p.outbox == nil {
return nil
}
return p.outbox.Send(ctx, env)
}
func (p *cardPayoutProcessor) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
provider, ok := p.store.(mntxTransactionProvider)
if !ok || provider == nil || provider.TransactionFactory() == nil {
return cb(ctx)
}
return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb)
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/gateway/mntx/internal/appversion"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage"
@@ -14,6 +15,7 @@ import (
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
@@ -25,10 +27,12 @@ type Service struct {
logger mlogger.Logger
clock clockpkg.Clock
producer msg.Producer
msgCfg pmodel.SettingsT
storage storage.Repository
config monetix.Config
httpClient *http.Client
card *cardPayoutProcessor
outbox gatewayoutbox.ReliableRuntime
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
announcer *discovery.Announcer
invokeURI string
@@ -64,6 +68,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
logger: logger.Named("service"),
clock: clockpkg.NewSystem(),
config: monetix.DefaultConfig(),
msgCfg: map[string]any{},
}
initMetrics()
@@ -85,6 +90,11 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
}
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer)
svc.card.outbox = &svc.outbox
svc.card.msgCfg = svc.msgCfg
if err := svc.card.startOutboxReliableProducer(); err != nil {
svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err))
}
svc.card.applyGatewayDescriptor(svc.gatewayDescriptor)
svc.startDiscoveryAnnouncer()
@@ -102,6 +112,7 @@ func (s *Service) Shutdown() {
if s == nil {
return
}
s.outbox.Stop()
if s.announcer != nil {
s.announcer.Stop()
}

View File

@@ -38,27 +38,49 @@ func toOpStatus(t *model.CardPayout) (rail.OperationResult, error) {
}
func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *model.CardPayout) error {
if err := p.store.Payouts().Upsert(ctx, state); err != nil {
if !isFinalStatus(state) {
if err := p.store.Payouts().Upsert(ctx, state); err != nil {
p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID),
zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)),
)
return err
}
return nil
}
_, err := p.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
if upsertErr := p.store.Payouts().Upsert(txCtx, state); upsertErr != nil {
return nil, upsertErr
}
if isFinalStatus(state) {
if emitErr := p.emitTransferStatusEvent(txCtx, state); emitErr != nil {
return nil, emitErr
}
}
return nil, nil
})
if err != nil {
p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID),
zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)),
)
}
if isFinalStatus(state) {
p.emitTransferStatusEvent(state)
return err
}
return nil
}
func (p *cardPayoutProcessor) emitTransferStatusEvent(payout *model.CardPayout) {
if p == nil || p.producer == nil || payout == nil {
return
func (p *cardPayoutProcessor) emitTransferStatusEvent(ctx context.Context, payout *model.CardPayout) error {
if p == nil || payout == nil {
return nil
}
if p.producer == nil || p.outboxStore() == nil {
return nil
}
status, err := toOpStatus(payout)
if err != nil {
p.logger.Warn("Failed to convert payout status to operation status for transfer status event", zap.Error(err),
mzap.ObjRef("payout_ref", payout.ID), zap.String("payment_ref", payout.PaymentRef), zap.String("status", string(payout.Status)))
return
return err
}
exec := pmodel.PaymentGatewayExecution{
@@ -75,7 +97,9 @@ func (p *cardPayoutProcessor) emitTransferStatusEvent(payout *model.CardPayout)
TransferRef: payout.GetID().Hex(),
}
env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec)
if err := p.producer.SendMessage(env); err != nil {
if err := p.sendWithOutbox(ctx, env); err != nil {
p.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", payout.ID))
return err
}
return nil
}

View File

@@ -4,9 +4,11 @@ import (
"context"
"time"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/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,11 +16,13 @@ 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
payouts storage.PayoutsStore
outbox gatewayoutbox.Store
}
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
@@ -42,9 +46,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()
@@ -57,7 +62,13 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
result.logger.Error("Failed to initialise payouts store", zap.Error(err), zap.String("store", "payments"))
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.payouts = payoutsStore
result.outbox = outboxStore
result.logger.Info("Payouts gateway MongoDB storage initialised")
return result, nil
}
@@ -66,4 +77,12 @@ func (r *Repository) Payouts() storage.PayoutsStore {
return r.payouts
}
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}
}