unified gateway interface
This commit is contained in:
157
api/pkg/discovery/announcer.go
Normal file
157
api/pkg/discovery/announcer.go
Normal 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
137
api/pkg/discovery/client.go
Normal 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...)
|
||||
}
|
||||
31
api/pkg/discovery/events.go
Normal file
31
api/pkg/discovery/events.go
Normal 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)
|
||||
}
|
||||
69
api/pkg/discovery/lookup.go
Normal file
69
api/pkg/discovery/lookup.go
Normal 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
|
||||
}
|
||||
56
api/pkg/discovery/messages.go
Normal file
56
api/pkg/discovery/messages.go
Normal 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)
|
||||
}
|
||||
258
api/pkg/discovery/registry.go
Normal file
258
api/pkg/discovery/registry.go
Normal 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
|
||||
}
|
||||
188
api/pkg/discovery/service.go
Normal file
188
api/pkg/discovery/service.go
Normal 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...)
|
||||
}
|
||||
10
api/pkg/discovery/subjects.go
Normal file
10
api/pkg/discovery/subjects.go
Normal 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"
|
||||
)
|
||||
40
api/pkg/discovery/types.go
Normal file
40
api/pkg/discovery/types.go
Normal 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"`
|
||||
}
|
||||
66
api/pkg/messaging/consumer/consumer.go
Normal file
66
api/pkg/messaging/consumer/consumer.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
83
api/pkg/payments/rail/gateway.go
Normal file
83
api/pkg/payments/rail/gateway.go
Normal 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)
|
||||
}
|
||||
38
api/pkg/payments/rail/ledger.go
Normal file
38
api/pkg/payments/rail/ledger.go
Normal 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
|
||||
}
|
||||
49
api/pkg/payments/types/chain.go
Normal file
49
api/pkg/payments/types/chain.go
Normal 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
|
||||
}
|
||||
94
api/pkg/payments/types/fees.go
Normal file
94
api/pkg/payments/types/fees.go
Normal 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"`
|
||||
}
|
||||
107
api/pkg/payments/types/fx.go
Normal file
107
api/pkg/payments/types/fx.go
Normal 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
|
||||
}
|
||||
33
api/pkg/payments/types/money.go
Normal file
33
api/pkg/payments/types/money.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user