unified gateway interface

This commit is contained in:
Stephan D
2025-12-31 17:47:32 +01:00
parent 19b7b69bd8
commit 97ba7500dc
104 changed files with 8228 additions and 1742 deletions

View File

@@ -0,0 +1,157 @@
package discovery
import (
"os"
"strings"
"sync"
"time"
"github.com/google/uuid"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
)
type Announcer struct {
logger mlogger.Logger
producer msg.Producer
sender string
announce Announcement
startOnce sync.Once
stopOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}
func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, announce Announcement) *Announcer {
if logger != nil {
logger = logger.Named("discovery")
}
announce = normalizeAnnouncement(announce)
if announce.Service == "" {
announce.Service = strings.TrimSpace(sender)
}
if announce.ID == "" {
announce.ID = DefaultInstanceID(announce.Service)
}
if announce.InvokeURI == "" && announce.Service != "" {
announce.InvokeURI = DefaultInvokeURI(announce.Service)
}
return &Announcer{
logger: logger,
producer: producer,
sender: strings.TrimSpace(sender),
announce: announce,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
func (a *Announcer) Start() {
if a == nil {
return
}
a.startOnce.Do(func() {
if a.producer == nil {
a.logWarn("Discovery announce skipped: producer not configured")
close(a.doneCh)
return
}
if strings.TrimSpace(a.announce.ID) == "" {
a.logWarn("Discovery announce skipped: missing instance id")
close(a.doneCh)
return
}
a.sendAnnouncement()
a.sendHeartbeat()
go a.heartbeatLoop()
})
}
func (a *Announcer) Stop() {
if a == nil {
return
}
a.stopOnce.Do(func() {
close(a.stopCh)
<-a.doneCh
})
}
func (a *Announcer) heartbeatLoop() {
defer close(a.doneCh)
interval := time.Duration(a.announce.Health.IntervalSec) * time.Second
if interval <= 0 {
interval = time.Duration(DefaultHealthIntervalSec) * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-a.stopCh:
return
case <-ticker.C:
a.sendHeartbeat()
}
}
}
func (a *Announcer) sendAnnouncement() {
env := NewServiceAnnounceEnvelope(a.sender, a.announce)
if a.announce.Rail != "" {
env = NewGatewayAnnounceEnvelope(a.sender, a.announce)
}
if err := a.producer.SendMessage(env); err != nil {
a.logWarn("Failed to publish discovery announce: " + err.Error())
return
}
a.logInfo("Discovery announce published")
}
func (a *Announcer) sendHeartbeat() {
hb := Heartbeat{
ID: a.announce.ID,
Status: "ok",
TS: time.Now().Unix(),
}
if err := a.producer.SendMessage(NewHeartbeatEnvelope(a.sender, hb)); err != nil {
a.logWarn("Failed to publish discovery heartbeat: " + err.Error())
}
}
func (a *Announcer) logInfo(message string) {
if a.logger == nil {
return
}
a.logger.Info(message)
}
func (a *Announcer) logWarn(message string) {
if a.logger == nil {
return
}
a.logger.Warn(message)
}
func DefaultInstanceID(service string) string {
clean := strings.ToLower(strings.TrimSpace(service))
if clean == "" {
clean = "service"
}
host, _ := os.Hostname()
host = strings.ToLower(strings.TrimSpace(host))
uid := uuid.NewString()
if host == "" {
return clean + "_" + uid
}
return clean + "_" + host + "_" + uid
}
func DefaultInvokeURI(service string) string {
clean := strings.ToLower(strings.TrimSpace(service))
if clean == "" {
return ""
}
return "grpc://" + clean
}

137
api/pkg/discovery/client.go Normal file
View File

@@ -0,0 +1,137 @@
package discovery
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"github.com/google/uuid"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Client struct {
logger mlogger.Logger
producer msg.Producer
consumer msg.Consumer
sender string
mu sync.Mutex
pending map[string]chan LookupResponse
}
func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, sender string) (*Client, error) {
if broker == nil {
return nil, errors.New("discovery client: broker is nil")
}
if logger != nil {
logger = logger.Named("discovery_client")
}
if producer == nil {
producer = msgproducer.NewProducer(logger, broker)
}
sender = strings.TrimSpace(sender)
if sender == "" {
sender = "discovery_client"
}
consumer, err := cons.NewConsumer(logger, broker, LookupResponseEvent())
if err != nil {
return nil, err
}
client := &Client{
logger: logger,
producer: producer,
consumer: consumer,
sender: sender,
pending: map[string]chan LookupResponse{},
}
go func() {
if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil {
client.logger.Warn("Discovery lookup consumer stopped", zap.Error(err))
}
}()
return client, nil
}
func (c *Client) Close() {
if c == nil {
return
}
if c.consumer != nil {
c.consumer.Close()
}
c.mu.Lock()
for key, ch := range c.pending {
close(ch)
delete(c.pending, key)
}
c.mu.Unlock()
}
func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) {
if c == nil || c.producer == nil {
return LookupResponse{}, errors.New("discovery client: producer not configured")
}
requestID := uuid.NewString()
ch := make(chan LookupResponse, 1)
c.mu.Lock()
c.pending[requestID] = ch
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.pending, requestID)
c.mu.Unlock()
}()
req := LookupRequest{RequestID: requestID}
if err := c.producer.SendMessage(NewLookupRequestEnvelope(c.sender, req)); err != nil {
return LookupResponse{}, err
}
select {
case <-ctx.Done():
return LookupResponse{}, ctx.Err()
case resp := <-ch:
return resp, nil
}
}
func (c *Client) handleLookupResponse(_ context.Context, env me.Envelope) error {
var payload LookupResponse
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
c.logWarn("Failed to decode discovery lookup response", zap.Error(err))
return err
}
requestID := strings.TrimSpace(payload.RequestID)
if requestID == "" {
return nil
}
c.mu.Lock()
ch := c.pending[requestID]
c.mu.Unlock()
if ch != nil {
ch <- payload
}
return nil
}
func (c *Client) logWarn(message string, fields ...zap.Field) {
if c == nil || c.logger == nil {
return
}
c.logger.Warn(message, fields...)
}

View File

@@ -0,0 +1,31 @@
package discovery
import (
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
)
func ServiceAnnounceEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryServiceAnnounce)
}
func GatewayAnnounceEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryGatewayAnnounce)
}
func HeartbeatEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryHeartbeat)
}
func LookupRequestEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupRequest)
}
func LookupResponseEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupResponse)
}
func RefreshUIEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryRefreshUI)
}

View File

@@ -0,0 +1,69 @@
package discovery
type LookupRequest struct {
RequestID string `json:"requestId,omitempty"`
}
type LookupResponse struct {
RequestID string `json:"requestId,omitempty"`
Services []ServiceSummary `json:"services,omitempty"`
Gateways []GatewaySummary `json:"gateways,omitempty"`
}
type ServiceSummary struct {
ID string `json:"id"`
Service string `json:"service"`
Ops []string `json:"ops,omitempty"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
}
type GatewaySummary struct {
ID string `json:"id"`
Rail string `json:"rail"`
Network string `json:"network,omitempty"`
Currencies []string `json:"currencies,omitempty"`
Ops []string `json:"ops,omitempty"`
Limits *Limits `json:"limits,omitempty"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
}
func (r *Registry) Lookup(now time.Time) LookupResponse {
entries := r.List(now, true)
resp := LookupResponse{
Services: make([]ServiceSummary, 0),
Gateways: make([]GatewaySummary, 0),
}
for _, entry := range entries {
if entry.Rail != "" {
resp.Gateways = append(resp.Gateways, GatewaySummary{
ID: entry.ID,
Rail: entry.Rail,
Network: entry.Network,
Currencies: cloneStrings(entry.Currencies),
Ops: cloneStrings(entry.Operations),
Limits: cloneLimits(entry.Limits),
Version: entry.Version,
Healthy: entry.Healthy,
RoutingPriority: entry.RoutingPriority,
InvokeURI: entry.InvokeURI,
})
continue
}
resp.Services = append(resp.Services, ServiceSummary{
ID: entry.ID,
Service: entry.Service,
Ops: cloneStrings(entry.Operations),
Version: entry.Version,
Healthy: entry.Healthy,
InvokeURI: entry.InvokeURI,
})
}
return resp
}

View File

@@ -0,0 +1,56 @@
package discovery
import (
"encoding/json"
"errors"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/model"
)
type jsonEnvelope struct {
messaging.Envelope
payload any
}
func (e *jsonEnvelope) Serialize() ([]byte, error) {
if e.payload == nil {
return nil, errors.New("discovery envelope payload is nil")
}
data, err := json.Marshal(e.payload)
if err != nil {
return nil, err
}
return e.Envelope.Wrap(data)
}
func newEnvelope(sender string, event model.NotificationEvent, payload any) messaging.Envelope {
return &jsonEnvelope{
Envelope: messaging.CreateEnvelope(sender, event),
payload: payload,
}
}
func NewServiceAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope {
return newEnvelope(sender, ServiceAnnounceEvent(), payload)
}
func NewGatewayAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope {
return newEnvelope(sender, GatewayAnnounceEvent(), payload)
}
func NewHeartbeatEnvelope(sender string, payload Heartbeat) messaging.Envelope {
return newEnvelope(sender, HeartbeatEvent(), payload)
}
func NewLookupRequestEnvelope(sender string, payload LookupRequest) messaging.Envelope {
return newEnvelope(sender, LookupRequestEvent(), payload)
}
func NewLookupResponseEnvelope(sender string, payload LookupResponse) messaging.Envelope {
return newEnvelope(sender, LookupResponseEvent(), payload)
}
func NewRefreshUIEnvelope(sender string, payload RefreshEvent) messaging.Envelope {
return newEnvelope(sender, RefreshUIEvent(), payload)
}

View File

@@ -0,0 +1,258 @@
package discovery
import (
"strings"
"sync"
"time"
)
const (
DefaultHealthIntervalSec = 10
DefaultHealthTimeoutSec = 30
)
type RegistryEntry struct {
ID string `json:"id"`
Service string `json:"service"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Operations []string `json:"operations,omitempty"`
Currencies []string `json:"currencies,omitempty"`
Limits *Limits `json:"limits,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"`
Version string `json:"version,omitempty"`
Health HealthParams `json:"health,omitempty"`
LastHeartbeat time.Time `json:"lastHeartbeat,omitempty"`
Status string `json:"status,omitempty"`
Healthy bool `json:"healthy,omitempty"`
}
type Registry struct {
mu sync.RWMutex
entries map[string]*RegistryEntry
}
type UpdateResult struct {
Entry RegistryEntry
IsNew bool
WasHealthy bool
BecameHealthy bool
}
func NewRegistry() *Registry {
return &Registry{
entries: map[string]*RegistryEntry{},
}
}
func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) UpdateResult {
entry := registryEntryFromAnnouncement(normalizeAnnouncement(announce), now)
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.entries[entry.ID]
wasHealthy := false
if ok && existing != nil {
wasHealthy = existing.isHealthyAt(now)
}
entry.Healthy = entry.isHealthyAt(now)
r.entries[entry.ID] = &entry
return UpdateResult{
Entry: entry,
IsNew: !ok,
WasHealthy: wasHealthy,
BecameHealthy: !wasHealthy && entry.Healthy,
}
}
func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now time.Time) (UpdateResult, bool) {
id = strings.TrimSpace(id)
if id == "" {
return UpdateResult{}, false
}
if status == "" {
status = "ok"
}
if ts.IsZero() {
ts = now
}
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.entries[id]
if !ok || entry == nil {
return UpdateResult{}, false
}
wasHealthy := entry.isHealthyAt(now)
entry.Status = status
entry.LastHeartbeat = ts
entry.Healthy = entry.isHealthyAt(now)
return UpdateResult{
Entry: *entry,
IsNew: false,
WasHealthy: wasHealthy,
BecameHealthy: !wasHealthy && entry.Healthy,
}, true
}
func (r *Registry) List(now time.Time, onlyHealthy bool) []RegistryEntry {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]RegistryEntry, 0, len(r.entries))
for _, entry := range r.entries {
if entry == nil {
continue
}
entry.Healthy = entry.isHealthyAt(now)
if onlyHealthy && !entry.Healthy {
continue
}
cp := *entry
result = append(result, cp)
}
return result
}
func registryEntryFromAnnouncement(announce Announcement, now time.Time) RegistryEntry {
status := "ok"
return RegistryEntry{
ID: strings.TrimSpace(announce.ID),
Service: strings.TrimSpace(announce.Service),
Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)),
Network: strings.ToUpper(strings.TrimSpace(announce.Network)),
Operations: cloneStrings(announce.Operations),
Currencies: cloneStrings(announce.Currencies),
Limits: cloneLimits(announce.Limits),
InvokeURI: strings.TrimSpace(announce.InvokeURI),
RoutingPriority: announce.RoutingPriority,
Version: strings.TrimSpace(announce.Version),
Health: normalizeHealth(announce.Health),
LastHeartbeat: now,
Status: status,
}
}
func normalizeAnnouncement(announce Announcement) Announcement {
announce.ID = strings.TrimSpace(announce.ID)
announce.Service = strings.TrimSpace(announce.Service)
announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail))
announce.Network = strings.ToUpper(strings.TrimSpace(announce.Network))
announce.Operations = normalizeStrings(announce.Operations, false)
announce.Currencies = normalizeStrings(announce.Currencies, true)
announce.InvokeURI = strings.TrimSpace(announce.InvokeURI)
announce.Version = strings.TrimSpace(announce.Version)
announce.Health = normalizeHealth(announce.Health)
if announce.Limits != nil {
announce.Limits = normalizeLimits(*announce.Limits)
}
return announce
}
func normalizeHealth(h HealthParams) HealthParams {
if h.IntervalSec <= 0 {
h.IntervalSec = DefaultHealthIntervalSec
}
if h.TimeoutSec <= 0 {
h.TimeoutSec = DefaultHealthTimeoutSec
}
if h.TimeoutSec < h.IntervalSec {
h.TimeoutSec = h.IntervalSec * 2
}
return h
}
func normalizeLimits(l Limits) *Limits {
res := l
if len(res.VolumeLimit) == 0 {
res.VolumeLimit = nil
}
if len(res.VelocityLimit) == 0 {
res.VelocityLimit = nil
}
return &res
}
func cloneLimits(src *Limits) *Limits {
if src == nil {
return nil
}
dst := *src
if src.VolumeLimit != nil {
dst.VolumeLimit = map[string]string{}
for key, value := range src.VolumeLimit {
if strings.TrimSpace(key) == "" {
continue
}
dst.VolumeLimit[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
if src.VelocityLimit != nil {
dst.VelocityLimit = map[string]int{}
for key, value := range src.VelocityLimit {
if strings.TrimSpace(key) == "" {
continue
}
dst.VelocityLimit[strings.TrimSpace(key)] = value
}
}
return &dst
}
func normalizeStrings(values []string, upper bool) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.TrimSpace(value)
if clean == "" {
continue
}
if upper {
clean = strings.ToUpper(clean)
}
if seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
if len(result) == 0 {
return nil
}
return result
}
func cloneStrings(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, len(values))
copy(out, values)
return out
}
func (e *RegistryEntry) isHealthyAt(now time.Time) bool {
if e == nil {
return false
}
status := strings.ToLower(strings.TrimSpace(e.Status))
if status != "" && status != "ok" {
return false
}
if e.LastHeartbeat.IsZero() {
return false
}
timeout := time.Duration(e.Health.TimeoutSec) * time.Second
if timeout <= 0 {
timeout = time.Duration(DefaultHealthTimeoutSec) * time.Second
}
return now.Sub(e.LastHeartbeat) <= timeout
}

View File

@@ -0,0 +1,188 @@
package discovery
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type RegistryService struct {
logger mlogger.Logger
registry *Registry
producer msg.Producer
sender string
consumers []consumerHandler
startOnce sync.Once
stopOnce sync.Once
}
type consumerHandler struct {
consumer msg.Consumer
handler msg.MessageHandlerT
}
func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) {
if broker == nil {
return nil, errors.New("discovery registry: broker is nil")
}
if registry == nil {
registry = NewRegistry()
}
if logger != nil {
logger = logger.Named("discovery_registry")
}
sender = strings.TrimSpace(sender)
if sender == "" {
sender = "discovery"
}
serviceConsumer, err := cons.NewConsumer(logger, broker, ServiceAnnounceEvent())
if err != nil {
return nil, err
}
gatewayConsumer, err := cons.NewConsumer(logger, broker, GatewayAnnounceEvent())
if err != nil {
return nil, err
}
heartbeatConsumer, err := cons.NewConsumer(logger, broker, HeartbeatEvent())
if err != nil {
return nil, err
}
lookupConsumer, err := cons.NewConsumer(logger, broker, LookupRequestEvent())
if err != nil {
return nil, err
}
svc := &RegistryService{
logger: logger,
registry: registry,
producer: producer,
sender: sender,
consumers: []consumerHandler{
{consumer: serviceConsumer, handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: gatewayConsumer, handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: heartbeatConsumer, handler: svc.handleHeartbeat},
{consumer: lookupConsumer, handler: svc.handleLookup},
},
}
return svc, nil
}
func (s *RegistryService) Start() {
if s == nil {
return
}
s.startOnce.Do(func() {
for _, ch := range s.consumers {
ch := ch
go func() {
if err := ch.consumer.ConsumeMessages(ch.handler); err != nil && s.logger != nil {
s.logger.Warn("Discovery consumer stopped with error", zap.Error(err))
}
}()
}
})
}
func (s *RegistryService) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
for _, ch := range s.consumers {
if ch.consumer != nil {
ch.consumer.Close()
}
}
})
}
func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) error {
var payload Announcement
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery announce payload", zap.Error(err))
return err
}
now := time.Now()
result := s.registry.UpsertFromAnnouncement(payload, now)
if result.IsNew || result.BecameHealthy {
s.publishRefresh(result.Entry)
}
return nil
}
func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) error {
var payload Heartbeat
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery heartbeat payload", zap.Error(err))
return err
}
if payload.ID == "" {
return nil
}
ts := time.Unix(payload.TS, 0)
if ts.Unix() <= 0 {
ts = time.Now()
}
result, ok := s.registry.UpdateHeartbeat(payload.ID, strings.TrimSpace(payload.Status), ts, time.Now())
if ok && result.BecameHealthy {
s.publishRefresh(result.Entry)
}
return nil
}
func (s *RegistryService) handleLookup(_ context.Context, env me.Envelope) error {
if s.producer == nil {
s.logWarn("Discovery lookup request ignored: producer not configured")
return nil
}
var payload LookupRequest
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery lookup payload", zap.Error(err))
return err
}
resp := s.registry.Lookup(time.Now())
resp.RequestID = strings.TrimSpace(payload.RequestID)
if err := s.producer.SendMessage(NewLookupResponseEnvelope(s.sender, resp)); err != nil {
s.logWarn("Failed to publish discovery lookup response", zap.Error(err))
return err
}
return nil
}
func (s *RegistryService) publishRefresh(entry RegistryEntry) {
if s == nil || s.producer == nil {
return
}
payload := RefreshEvent{
Service: entry.Service,
Rail: entry.Rail,
Network: entry.Network,
Message: "new module available",
}
if err := s.producer.SendMessage(NewRefreshUIEnvelope(s.sender, payload)); err != nil {
s.logWarn("Failed to publish discovery refresh event", zap.Error(err))
}
}
func (s *RegistryService) logWarn(message string, fields ...zap.Field) {
if s.logger == nil {
return
}
s.logger.Warn(message, fields...)
}

View File

@@ -0,0 +1,10 @@
package discovery
const (
SubjectServiceAnnounce = "discovery.service.announce"
SubjectGatewayAnnounce = "discovery.gateway.announce"
SubjectHeartbeat = "discovery.service.heartbeat"
SubjectLookupRequest = "discovery.request.lookup"
SubjectLookupResponse = "discovery.response.lookup"
SubjectRefreshUI = "discovery.event.refresh_ui"
)

View File

@@ -0,0 +1,40 @@
package discovery
type HealthParams struct {
IntervalSec int `json:"intervalSec"`
TimeoutSec int `json:"timeoutSec"`
}
type Limits struct {
MinAmount string `json:"minAmount,omitempty"`
MaxAmount string `json:"maxAmount,omitempty"`
VolumeLimit map[string]string `json:"volumeLimit,omitempty"`
VelocityLimit map[string]int `json:"velocityLimit,omitempty"`
}
type Announcement struct {
ID string `json:"id"`
Service string `json:"service"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Operations []string `json:"operations,omitempty"`
Currencies []string `json:"currencies,omitempty"`
Limits *Limits `json:"limits,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"`
Version string `json:"version,omitempty"`
Health HealthParams `json:"health,omitempty"`
}
type Heartbeat struct {
ID string `json:"id"`
Status string `json:"status"`
TS int64 `json:"ts"`
}
type RefreshEvent struct {
Service string `json:"service,omitempty"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Message string `json:"message,omitempty"`
}

View File

@@ -0,0 +1,66 @@
package consumer
import (
"context"
messaging "github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type ChannelConsumer struct {
logger mlogger.Logger
broker mb.Broker
event model.NotificationEvent
ch <-chan me.Envelope
ctx context.Context
cancel context.CancelFunc
}
func (c *ChannelConsumer) ConsumeMessages(handleFunc messaging.MessageHandlerT) error {
c.logger.Info("Message consumer is ready")
for {
select {
case msg := <-c.ch:
if msg == nil { // nil message indicates the channel was closed
c.logger.Info("Consumer shutting down")
return nil
}
if err := handleFunc(c.ctx, msg); err != nil {
c.logger.Warn("Error processing message", zap.Error(err))
}
case <-c.ctx.Done():
c.logger.Info("Context done, shutting down")
return c.ctx.Err()
}
}
}
func (c *ChannelConsumer) Close() {
c.logger.Info("Shutting down...")
c.cancel()
if err := c.broker.Unsubscribe(c.event, c.ch); err != nil {
c.logger.Warn("Failed to unsubscribe", zap.Error(err))
}
}
func NewConsumer(logger mlogger.Logger, broker mb.Broker, event model.NotificationEvent) (*ChannelConsumer, error) {
ctx, cancel := context.WithCancel(context.Background())
ch, err := broker.Subscribe(event)
if err != nil {
logger.Warn("Failed to create channel consumer", zap.Error(err), zap.String("topic", event.ToString()))
cancel()
return nil, err
}
return &ChannelConsumer{
logger: logger.Named("consumer").Named(event.ToString()),
broker: broker,
event: event,
ch: ch,
ctx: ctx,
cancel: cancel,
}, nil
}

View File

@@ -11,4 +11,11 @@ const (
NAArchived NotificationAction = "archived"
NASent NotificationAction = "sent"
NAPasswordReset NotificationAction = "password_reset"
NADiscoveryServiceAnnounce NotificationAction = "service.announce"
NADiscoveryGatewayAnnounce NotificationAction = "gateway.announce"
NADiscoveryHeartbeat NotificationAction = "service.heartbeat"
NADiscoveryLookupRequest NotificationAction = "request.lookup"
NADiscoveryLookupResponse NotificationAction = "response.lookup"
NADiscoveryRefreshUI NotificationAction = "event.refresh_ui"
)

View File

@@ -36,7 +36,11 @@ func (ne *NotificationEventImp) Equals(other *NotificationEventImp) bool {
}
func (ne *NotificationEventImp) ToString() string {
return ne.StringType() + messageDelimiter + ne.StringAction()
action := ne.StringAction()
if strings.Contains(action, ".") {
return ne.StringType() + "." + action
}
return ne.StringType() + messageDelimiter + action
}
func (ne *NotificationEventImp) StringType() string {
@@ -76,7 +80,13 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) {
nm.NAUpdated,
nm.NADeleted,
nm.NAAssigned,
nm.NAPasswordReset:
nm.NAPasswordReset,
nm.NADiscoveryServiceAnnounce,
nm.NADiscoveryGatewayAnnounce,
nm.NADiscoveryHeartbeat,
nm.NADiscoveryLookupRequest,
nm.NADiscoveryLookupResponse,
nm.NADiscoveryRefreshUI:
return nm.NotificationAction(s), nil
default:
return "", merrors.DataConflict("invalid Notification action: " + s)

View File

@@ -8,6 +8,7 @@ const (
Accounts Type = "accounts" // Represents user accounts in the system
Confirmations Type = "confirmations" // Represents confirmation code flows
Amplitude Type = "amplitude" // Represents analytics integration with Amplitude
Discovery Type = "discovery" // Represents service discovery registry
Site Type = "site" // Represents public site endpoints
Changes Type = "changes" // Tracks changes made to resources
Clients Type = "clients" // Represents client information
@@ -34,6 +35,7 @@ const (
Notifications Type = "notifications" // Represents notifications sent to users
Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
PaymentMethods Type = "payment_methods" // Represents payment methods service
Permissions Type = "permissions" // Represents permissiosns service
Policies Type = "policies" // Represents access control policies
@@ -52,8 +54,8 @@ func StringToSType(s string) (Type, error) {
case Accounts, Confirmations, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, ChainWalletBalances,
ChainTransfers, ChainDeposits, MntxGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger,
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
Organizations, Payments, PaymentOrchestrator, Permissions, Policies, PolicyAssignements,
RefreshTokens, Roles, Storage, Tenants, Workflows:
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, Permissions, Policies, PolicyAssignements,
RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:
return Type(s), nil
default:
return "", merrors.InvalidArgument("invalid service type", s)

View File

@@ -0,0 +1,83 @@
package rail
import (
"context"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
// Money represents a currency amount using decimal-safe strings.
type Money = paymenttypes.Money
const (
TransferStatusUnspecified = "UNSPECIFIED"
TransferStatusSuccess = "SUCCESS"
TransferStatusFailed = "FAILED"
TransferStatusRejected = "REJECTED"
TransferStatusPending = "PENDING"
)
// RailCapabilities are declared per gateway instance.
type RailCapabilities struct {
CanPayIn bool
CanPayOut bool
CanReadBalance bool
CanSendFee bool
RequiresObserveConfirm bool
}
// FeeBreakdown provides a gateway-level fee description.
type FeeBreakdown struct {
FeeCode string
Amount *Money
Description string
}
// TransferRequest defines the inputs for sending value through a rail gateway.
type TransferRequest struct {
OrganizationRef string
FromAccountID string
ToAccountID string
Currency string
Network string
Amount string
Fee *Money
Fees []FeeBreakdown
IdempotencyKey string
Metadata map[string]string
ClientReference string
DestinationMemo string
}
// RailResult reports the outcome of a rail gateway operation.
type RailResult struct {
ReferenceID string
Status string
FinalAmount *Money
Error *RailError
}
// ObserveResult reports the outcome of a confirmation observation.
type ObserveResult struct {
ReferenceID string
Status string
FinalAmount *Money
Error *RailError
}
// RailError captures structured failure details from a gateway.
type RailError struct {
Code string
Message string
CanRetry bool
ShouldRollback bool
}
// RailGateway exposes unified gateway operations for external rails.
type RailGateway interface {
Rail() string
Network() string
Capabilities() RailCapabilities
Send(ctx context.Context, req TransferRequest) (RailResult, error)
Observe(ctx context.Context, referenceID string) (ObserveResult, error)
}

View File

@@ -0,0 +1,38 @@
package rail
import (
"context"
"time"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
)
// InternalLedger exposes unified ledger operations for orchestration.
type InternalLedger interface {
ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error)
CreateTransaction(ctx context.Context, tx LedgerTx) (string, error)
HoldBalance(ctx context.Context, accountID string, amount string) error
}
// LedgerTx captures ledger posting context used by orchestration.
type LedgerTx struct {
PaymentPlanID string
Currency string
Amount string
FeeAmount string
FromRail string
ToRail string
ExternalReferenceID string
FXRateUsed string
IdempotencyKey string
CreatedAt time.Time
// Internal fields required to map into the ledger API.
OrganizationRef string
LedgerAccountRef string
ContraLedgerAccountRef string
Description string
Charges []*ledgerv1.PostingLine
Metadata map[string]string
}

View File

@@ -0,0 +1,49 @@
package types
// Asset captures a chain and token identifier.
type Asset struct {
Chain string `bson:"chain,omitempty" json:"chain,omitempty"`
TokenSymbol string `bson:"tokenSymbol,omitempty" json:"tokenSymbol,omitempty"`
ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"`
}
func (a *Asset) GetChain() string {
if a == nil {
return ""
}
return a.Chain
}
func (a *Asset) GetTokenSymbol() string {
if a == nil {
return ""
}
return a.TokenSymbol
}
func (a *Asset) GetContractAddress() string {
if a == nil {
return ""
}
return a.ContractAddress
}
// NetworkFeeEstimate captures network fee estimation output.
type NetworkFeeEstimate struct {
NetworkFee *Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
EstimationContext string `bson:"estimationContext,omitempty" json:"estimationContext,omitempty"`
}
func (n *NetworkFeeEstimate) GetNetworkFee() *Money {
if n == nil {
return nil
}
return n.NetworkFee
}
func (n *NetworkFeeEstimate) GetEstimationContext() string {
if n == nil {
return ""
}
return n.EstimationContext
}

View File

@@ -0,0 +1,94 @@
package types
// InsufficientNetPolicy indicates how to handle insufficient net funds for fees.
type InsufficientNetPolicy string
const (
InsufficientNetUnspecified InsufficientNetPolicy = "UNSPECIFIED"
InsufficientNetBlockPosting InsufficientNetPolicy = "BLOCK_POSTING"
InsufficientNetSweepOrgCash InsufficientNetPolicy = "SWEEP_ORG_CASH"
InsufficientNetInvoiceLater InsufficientNetPolicy = "INVOICE_LATER"
)
// FeePolicy captures optional fee policy overrides.
type FeePolicy struct {
InsufficientNet InsufficientNetPolicy `bson:"insufficientNet,omitempty" json:"insufficientNet,omitempty"`
}
// EntrySide captures debit/credit semantics for fee lines.
type EntrySide string
const (
EntrySideUnspecified EntrySide = "UNSPECIFIED"
EntrySideDebit EntrySide = "DEBIT"
EntrySideCredit EntrySide = "CREDIT"
)
// PostingLineType captures the semantic type of a fee line.
type PostingLineType string
const (
PostingLineTypeUnspecified PostingLineType = "UNSPECIFIED"
PostingLineTypeFee PostingLineType = "FEE"
PostingLineTypeTax PostingLineType = "TAX"
PostingLineTypeSpread PostingLineType = "SPREAD"
PostingLineTypeReversal PostingLineType = "REVERSAL"
)
// RoundingMode captures rounding behavior for fee rules.
type RoundingMode string
const (
RoundingModeUnspecified RoundingMode = "UNSPECIFIED"
RoundingModeHalfEven RoundingMode = "HALF_EVEN"
RoundingModeHalfUp RoundingMode = "HALF_UP"
RoundingModeDown RoundingMode = "DOWN"
)
// FeeLine stores derived fee posting data.
type FeeLine struct {
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
Money *Money `bson:"money,omitempty" json:"money,omitempty"`
LineType PostingLineType `bson:"lineType,omitempty" json:"lineType,omitempty"`
Side EntrySide `bson:"side,omitempty" json:"side,omitempty"`
Meta map[string]string `bson:"meta,omitempty" json:"meta,omitempty"`
}
func (l *FeeLine) GetMoney() *Money {
if l == nil {
return nil
}
return l.Money
}
func (l *FeeLine) GetSide() EntrySide {
if l == nil {
return EntrySideUnspecified
}
return l.Side
}
func (l *FeeLine) GetLineType() PostingLineType {
if l == nil {
return PostingLineTypeUnspecified
}
return l.LineType
}
func (l *FeeLine) GetLedgerAccountRef() string {
if l == nil {
return ""
}
return l.LedgerAccountRef
}
// AppliedRule stores fee rule audit data.
type AppliedRule struct {
RuleID string `bson:"ruleId,omitempty" json:"ruleId,omitempty"`
RuleVersion string `bson:"ruleVersion,omitempty" json:"ruleVersion,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Rounding RoundingMode `bson:"rounding,omitempty" json:"rounding,omitempty"`
TaxCode string `bson:"taxCode,omitempty" json:"taxCode,omitempty"`
TaxRate string `bson:"taxRate,omitempty" json:"taxRate,omitempty"`
Parameters map[string]string `bson:"parameters,omitempty" json:"parameters,omitempty"`
}

View File

@@ -0,0 +1,107 @@
package types
// CurrencyPair describes base/quote currencies.
type CurrencyPair struct {
Base string `bson:"base,omitempty" json:"base,omitempty"`
Quote string `bson:"quote,omitempty" json:"quote,omitempty"`
}
func (p *CurrencyPair) GetBase() string {
if p == nil {
return ""
}
return p.Base
}
func (p *CurrencyPair) GetQuote() string {
if p == nil {
return ""
}
return p.Quote
}
// FXSide indicates the direction of an FX trade.
type FXSide string
const (
FXSideUnspecified FXSide = "UNSPECIFIED"
FXSideBuyBaseSellQuote FXSide = "BUY_BASE_SELL_QUOTE"
FXSideSellBaseBuyQuote FXSide = "SELL_BASE_BUY_QUOTE"
)
// FXQuote captures a priced FX quote.
type FXQuote struct {
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
Pair *CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"`
Side FXSide `bson:"side,omitempty" json:"side,omitempty"`
Price *Decimal `bson:"price,omitempty" json:"price,omitempty"`
BaseAmount *Money `bson:"baseAmount,omitempty" json:"baseAmount,omitempty"`
QuoteAmount *Money `bson:"quoteAmount,omitempty" json:"quoteAmount,omitempty"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs,omitempty" json:"expiresAtUnixMs,omitempty"`
Provider string `bson:"provider,omitempty" json:"provider,omitempty"`
RateRef string `bson:"rateRef,omitempty" json:"rateRef,omitempty"`
Firm bool `bson:"firm,omitempty" json:"firm,omitempty"`
}
func (q *FXQuote) GetPair() *CurrencyPair {
if q == nil {
return nil
}
return q.Pair
}
func (q *FXQuote) GetSide() FXSide {
if q == nil {
return FXSideUnspecified
}
return q.Side
}
func (q *FXQuote) GetPrice() *Decimal {
if q == nil {
return nil
}
return q.Price
}
func (q *FXQuote) GetBaseAmount() *Money {
if q == nil {
return nil
}
return q.BaseAmount
}
func (q *FXQuote) GetQuoteAmount() *Money {
if q == nil {
return nil
}
return q.QuoteAmount
}
func (q *FXQuote) GetExpiresAtUnixMs() int64 {
if q == nil {
return 0
}
return q.ExpiresAtUnixMs
}
func (q *FXQuote) GetProvider() string {
if q == nil {
return ""
}
return q.Provider
}
func (q *FXQuote) GetRateRef() string {
if q == nil {
return ""
}
return q.RateRef
}
func (q *FXQuote) GetFirm() bool {
if q == nil {
return false
}
return q.Firm
}

View File

@@ -0,0 +1,33 @@
package types
// Decimal holds a decimal value as a string.
type Decimal struct {
Value string `bson:"value,omitempty" json:"value,omitempty"`
}
func (d *Decimal) GetValue() string {
if d == nil {
return ""
}
return d.Value
}
// Money represents a currency amount using decimal-safe strings.
type Money struct {
Amount string `bson:"amount,omitempty" json:"amount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
}
func (m *Money) GetAmount() string {
if m == nil {
return ""
}
return m.Amount
}
func (m *Money) GetCurrency() string {
if m == nil {
return ""
}
return m.Currency
}