refactored notificatoin / tgsettle responsibility boundaries #534

Merged
tech merged 1 commits from tg-530 into main 2026-02-19 17:57:48 +00:00
73 changed files with 3705 additions and 681 deletions

View File

@@ -38,6 +38,6 @@ messaging:
gateway: gateway:
rail: "provider_settlement" rail: "provider_settlement"
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
timeout_seconds: 259200 timeout_seconds: 345600
accepted_user_ids: [] accepted_user_ids: []
success_reaction: "\U0001FAE1" success_reaction: "\U0001FAE1"

View File

@@ -38,6 +38,6 @@ messaging:
gateway: gateway:
rail: "provider_settlement" rail: "provider_settlement"
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
timeout_seconds: 259200 timeout_seconds: 345600
accepted_user_ids: [] accepted_user_ids: []
success_reaction: "\U0001FAE1" success_reaction: "\U0001FAE1"

View File

@@ -0,0 +1,370 @@
package gateway
import (
"context"
"errors"
"regexp"
"strings"
"time"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`)
func (s *Service) startConfirmationTimeoutWatcher() {
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return
}
if s.timeoutCancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
s.timeoutCtx = ctx
s.timeoutCancel = cancel
s.timeoutWG.Add(1)
go func() {
defer s.timeoutWG.Done()
ticker := time.NewTicker(defaultConfirmationSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sweepExpiredConfirmations(ctx)
}
}
}()
}
func (s *Service) sweepExpiredConfirmations(ctx context.Context) {
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return
}
expired, err := s.repo.PendingConfirmations().ListExpired(ctx, time.Now(), 100)
if err != nil {
s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err))
return
}
for _, pending := range expired {
if pending == nil || strings.TrimSpace(pending.RequestID) == "" {
continue
}
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Status: model.ConfirmationStatusTimeout,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
s.logger.Warn("Failed to publish timeout confirmation result", zap.Error(err), zap.String("request_id", pending.RequestID))
continue
}
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
s.logger.Warn("Failed to remove expired pending confirmation", zap.Error(err), zap.String("request_id", pending.RequestID))
}
}
}
func (s *Service) persistPendingConfirmation(ctx context.Context, request *model.ConfirmationRequest) error {
if request == nil {
return merrors.InvalidArgument("confirmation request is nil", "request")
}
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return merrors.Internal("pending confirmations store unavailable")
}
timeout := request.TimeoutSeconds
if timeout <= 0 {
timeout = int32(defaultConfirmationTimeoutSeconds)
}
pending := &storagemodel.PendingConfirmation{
RequestID: strings.TrimSpace(request.RequestID),
TargetChatID: strings.TrimSpace(request.TargetChatID),
AcceptedUserIDs: normalizeStringList(request.AcceptedUserIDs),
RequestedMoney: request.RequestedMoney,
SourceService: strings.TrimSpace(request.SourceService),
Rail: strings.TrimSpace(request.Rail),
ExpiresAt: time.Now().Add(time.Duration(timeout) * time.Second),
}
return s.repo.PendingConfirmations().Upsert(ctx, pending)
}
func (s *Service) clearPendingConfirmation(ctx context.Context, requestID string) error {
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return nil
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return nil
}
return s.repo.PendingConfirmations().DeleteByRequestID(ctx, requestID)
}
func (s *Service) onConfirmationDispatch(ctx context.Context, dispatch *model.ConfirmationRequestDispatch) error {
if dispatch == nil {
return merrors.InvalidArgument("confirmation dispatch is nil", "dispatch")
}
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return merrors.Internal("pending confirmations store unavailable")
}
requestID := strings.TrimSpace(dispatch.RequestID)
messageID := strings.TrimSpace(dispatch.MessageID)
if requestID == "" {
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
}
if messageID == "" {
return merrors.InvalidArgument("confirmation message_id is required", "message_id")
}
if err := s.repo.PendingConfirmations().AttachMessage(ctx, requestID, messageID); err != nil {
if errors.Is(err, merrors.ErrNoData) {
s.logger.Info("Confirmation dispatch ignored: pending request not found",
zap.String("request_id", requestID),
zap.String("message_id", messageID))
return nil
}
s.logger.Warn("Failed to attach confirmation message id", zap.Error(err), zap.String("request_id", requestID), zap.String("message_id", messageID))
return err
}
s.logger.Info("Pending confirmation message attached", zap.String("request_id", requestID), zap.String("message_id", messageID))
return nil
}
func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) error {
if update == nil || update.Message == nil {
return nil
}
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
return merrors.Internal("pending confirmations store unavailable")
}
message := update.Message
replyToID := strings.TrimSpace(message.ReplyToMessageID)
if replyToID == "" {
return nil
}
pending, err := s.repo.PendingConfirmations().FindByMessageID(ctx, replyToID)
if err != nil {
return err
}
if pending == nil {
s.logger.Debug("Telegram reply ignored: no pending confirmation for message", zap.String("reply_to_message_id", replyToID), zap.Int64("update_id", update.UpdateID))
return nil
}
if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) {
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Status: model.ConfirmationStatusTimeout,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
return err
}
return s.clearPendingConfirmation(ctx, pending.RequestID)
}
if strings.TrimSpace(message.ChatID) != strings.TrimSpace(pending.TargetChatID) {
s.logger.Debug("Telegram reply ignored: chat mismatch",
zap.String("request_id", pending.RequestID),
zap.String("expected_chat_id", pending.TargetChatID),
zap.String("chat_id", strings.TrimSpace(message.ChatID)))
return nil
}
if !isUserAllowed(message.FromUserID, pending.AcceptedUserIDs) {
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Status: model.ConfirmationStatusRejected,
ParseError: "unauthorized_user",
RawReply: message,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
return err
}
_ = s.sendTelegramText(ctx, &model.TelegramTextRequest{
RequestID: pending.RequestID,
ChatID: pending.TargetChatID,
ReplyToMessageID: message.MessageID,
Text: "Only approved users can confirm this payment.",
})
return s.clearPendingConfirmation(ctx, pending.RequestID)
}
money, reason, err := parseConfirmationReply(message.Text)
if err != nil {
if markErr := s.repo.PendingConfirmations().MarkClarified(ctx, pending.RequestID); markErr != nil {
s.logger.Warn("Failed to mark confirmation as clarified", zap.Error(markErr), zap.String("request_id", pending.RequestID))
}
_ = s.sendTelegramText(ctx, &model.TelegramTextRequest{
RequestID: pending.RequestID,
ChatID: pending.TargetChatID,
ReplyToMessageID: message.MessageID,
Text: clarificationMessage(reason),
})
return nil
}
status := model.ConfirmationStatusConfirmed
if pending.Clarified {
status = model.ConfirmationStatusClarified
}
result := &model.ConfirmationResult{
RequestID: pending.RequestID,
Money: money,
RawReply: message,
Status: status,
}
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
return err
}
return s.clearPendingConfirmation(ctx, pending.RequestID)
}
func (s *Service) publishPendingConfirmationResult(pending *storagemodel.PendingConfirmation, result *model.ConfirmationResult) error {
if pending == nil || result == nil {
return merrors.InvalidArgument("pending confirmation context is required")
}
if s == nil || s.producer == nil {
return merrors.Internal("messaging producer is not configured")
}
sourceService := strings.TrimSpace(pending.SourceService)
if sourceService == "" {
sourceService = string(mservice.PaymentGateway)
}
rail := strings.TrimSpace(pending.Rail)
if rail == "" {
rail = s.rail
}
env := confirmations.ConfirmationResult(string(mservice.PaymentGateway), result, sourceService, rail)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish confirmation result", zap.Error(err),
zap.String("request_id", strings.TrimSpace(result.RequestID)),
zap.String("status", string(result.Status)),
zap.String("source_service", sourceService),
zap.String("rail", rail))
return err
}
return nil
}
func (s *Service) sendTelegramText(ctx context.Context, request *model.TelegramTextRequest) error {
if request == nil {
return merrors.InvalidArgument("telegram text request is nil", "request")
}
if s == nil || s.producer == nil {
return merrors.Internal("messaging producer is not configured")
}
request.ChatID = strings.TrimSpace(request.ChatID)
request.Text = strings.TrimSpace(request.Text)
request.ReplyToMessageID = strings.TrimSpace(request.ReplyToMessageID)
if request.ChatID == "" || request.Text == "" {
return merrors.InvalidArgument("telegram chat_id and text are required", "chat_id", "text")
}
env := tnotifications.TelegramText(string(mservice.PaymentGateway), request)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish telegram text request", zap.Error(err),
zap.String("request_id", request.RequestID),
zap.String("chat_id", request.ChatID),
zap.String("reply_to_message_id", request.ReplyToMessageID))
return err
}
return nil
}
func isFinalConfirmationStatus(status model.ConfirmationStatus) bool {
switch status {
case model.ConfirmationStatusConfirmed, model.ConfirmationStatusRejected, model.ConfirmationStatusTimeout, model.ConfirmationStatusClarified:
return true
default:
return false
}
}
func isUserAllowed(userID string, allowed []string) bool {
allowed = normalizeStringList(allowed)
if len(allowed) == 0 {
return true
}
userID = strings.TrimSpace(userID)
if userID == "" {
return false
}
for _, id := range allowed {
if id == userID {
return true
}
}
return false
}
func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, "empty", merrors.InvalidArgument("empty reply")
}
parts := strings.Fields(text)
if len(parts) < 2 {
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
}
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
}
if len(parts) > 2 {
return nil, "format", merrors.InvalidArgument("reply format is invalid")
}
amount := parts[0]
currency := parts[1]
if !amountPattern.MatchString(amount) {
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
}
if !currencyPattern.MatchString(currency) {
return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
}
return &paymenttypes.Money{
Amount: amount,
Currency: strings.ToUpper(currency),
}, "", nil
}
func clarificationMessage(reason string) string {
switch reason {
case "missing_currency":
return "Currency code is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "missing_amount":
return "Amount is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "invalid_amount":
return "Amount must be a decimal number. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "invalid_currency":
return "Currency must be a code like USD or EUR. Reply with \"<amount> <currency>\"."
default:
return "Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
}
}
func normalizeStringList(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
if len(result) == 0 {
return nil
}
return result
}

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
@@ -36,8 +37,9 @@ import (
) )
const ( const (
defaultConfirmationTimeoutSeconds = 259200 defaultConfirmationTimeoutSeconds = 345600
defaultTelegramSuccessReaction = "\U0001FAE1" defaultTelegramSuccessReaction = "\U0001FAE1"
defaultConfirmationSweepInterval = 5 * time.Second
) )
const ( const (
@@ -74,6 +76,9 @@ type Service struct {
outbox gatewayoutbox.ReliableRuntime outbox gatewayoutbox.ReliableRuntime
consumers []msg.Consumer consumers []msg.Consumer
timeoutCtx context.Context
timeoutCancel context.CancelFunc
timeoutWG sync.WaitGroup
connectorv1.UnimplementedConnectorServiceServer connectorv1.UnimplementedConnectorServiceServer
} }
@@ -103,6 +108,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
} }
svc.startConsumers() svc.startConsumers()
svc.startAnnouncer() svc.startAnnouncer()
svc.startConfirmationTimeoutWatcher()
return svc return svc
} }
@@ -125,6 +131,10 @@ func (s *Service) Shutdown() {
consumer.Close() consumer.Close()
} }
} }
if s.timeoutCancel != nil {
s.timeoutCancel()
}
s.timeoutWG.Wait()
} }
func (s *Service) startConsumers() { func (s *Service) startConsumers() {
@@ -136,6 +146,10 @@ func (s *Service) startConsumers() {
} }
resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult) resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult)
s.consumeProcessor(resultProcessor) s.consumeProcessor(resultProcessor)
dispatchProcessor := confirmations.NewConfirmationDispatchProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationDispatch)
s.consumeProcessor(dispatchProcessor)
updateProcessor := tnotifications.NewTelegramUpdateProcessor(s.logger, s.onTelegramUpdate)
s.consumeProcessor(updateProcessor)
} }
func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) { func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) {
@@ -300,8 +314,13 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte
zap.String("idempotency_key", confirmReq.RequestID), zap.String("intent_ref", record.IntentRef)) zap.String("idempotency_key", confirmReq.RequestID), zap.String("intent_ref", record.IntentRef))
return err return err
} }
if err := s.persistPendingConfirmation(ctx, confirmReq); err != nil {
s.logger.Warn("Failed to persist pending confirmation", zap.Error(err), zap.String("request_id", confirmReq.RequestID))
return err
}
if err := s.sendConfirmationRequest(confirmReq); err != nil { if err := s.sendConfirmationRequest(confirmReq); err != nil {
s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID)) s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
_ = s.clearPendingConfirmation(ctx, confirmReq.RequestID)
// If the confirmation request was not sent, we keep the record in waiting // If the confirmation request was not sent, we keep the record in waiting
// (or it can be marked as failed — depending on your semantics). // (or it can be marked as failed — depending on your semantics).
// Here, failed is chosen to avoid it hanging indefinitely. // Here, failed is chosen to avoid it hanging indefinitely.
@@ -392,6 +411,10 @@ func (s *Service) onConfirmationResult(ctx context.Context, result *model.Confir
return err return err
} }
if isFinalConfirmationStatus(result.Status) {
_ = s.clearPendingConfirmation(ctx, requestID)
}
s.publishTelegramReaction(result) s.publishTelegramReaction(result)
return nil return nil

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"sync" "sync"
"testing" "testing"
"time"
"github.com/tech/sendico/gateway/tgsettle/storage" "github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
@@ -61,6 +62,7 @@ func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.Teleg
type fakeRepo struct { type fakeRepo struct {
payments *fakePaymentsStore payments *fakePaymentsStore
tg *fakeTelegramStore tg *fakeTelegramStore
pending *fakePendingStore
} }
func (f *fakeRepo) Payments() storage.PaymentsStore { func (f *fakeRepo) Payments() storage.PaymentsStore {
@@ -71,6 +73,93 @@ func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
return f.tg return f.tg
} }
func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
return f.pending
}
type fakePendingStore struct {
mu sync.Mutex
records map[string]*storagemodel.PendingConfirmation
}
func (f *fakePendingStore) Upsert(_ context.Context, record *storagemodel.PendingConfirmation) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
f.records = map[string]*storagemodel.PendingConfirmation{}
}
cp := *record
f.records[record.RequestID] = &cp
return nil
}
func (f *fakePendingStore) FindByRequestID(_ context.Context, requestID string) (*storagemodel.PendingConfirmation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
return nil, nil
}
return f.records[requestID], nil
}
func (f *fakePendingStore) FindByMessageID(_ context.Context, messageID string) (*storagemodel.PendingConfirmation, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, record := range f.records {
if record != nil && record.MessageID == messageID {
return record, nil
}
}
return nil, nil
}
func (f *fakePendingStore) MarkClarified(_ context.Context, requestID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if record := f.records[requestID]; record != nil {
record.Clarified = true
}
return nil
}
func (f *fakePendingStore) AttachMessage(_ context.Context, requestID string, messageID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if record := f.records[requestID]; record != nil {
if record.MessageID == "" {
record.MessageID = messageID
}
}
return nil
}
func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.records, requestID)
return nil
}
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]*storagemodel.PendingConfirmation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if limit <= 0 {
limit = 100
}
result := make([]*storagemodel.PendingConfirmation, 0)
for _, record := range f.records {
if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) {
continue
}
cp := *record
result = append(result, &cp)
if int64(len(result)) >= limit {
break
}
}
return result, nil
}
// //
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА) // FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
// //
@@ -119,6 +208,7 @@ func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
repo := &fakeRepo{ repo := &fakeRepo{
payments: &fakePaymentsStore{}, payments: &fakePaymentsStore{},
tg: &fakeTelegramStore{}, tg: &fakeTelegramStore{},
pending: &fakePendingStore{},
} }
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{ sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{

View File

@@ -48,3 +48,18 @@ type TelegramConfirmation struct {
RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"`
ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"` ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"`
} }
type PendingConfirmation struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
AcceptedUserIDs []string `bson:"acceptedUserIds,omitempty" json:"accepted_user_ids,omitempty"`
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"`
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
Clarified bool `bson:"clarified,omitempty" json:"clarified,omitempty"`
ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"`
CreatedAt time.Time `bson:"createdAt,omitempty" json:"created_at,omitempty"`
UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updated_at,omitempty"`
}

View File

@@ -23,6 +23,7 @@ type Repository struct {
payments storage.PaymentsStore payments storage.PaymentsStore
tg storage.TelegramConfirmationsStore tg storage.TelegramConfirmationsStore
pending storage.PendingConfirmationsStore
outbox gatewayoutbox.Store outbox gatewayoutbox.Store
} }
@@ -68,6 +69,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations")) result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations"))
return nil, err return nil, err
} }
pendingStore, err := store.NewPendingConfirmations(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise pending confirmations store", zap.Error(err), zap.String("store", "pending_confirmations"))
return nil, err
}
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
if err != nil { if err != nil {
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
@@ -75,6 +81,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
} }
result.payments = paymentsStore result.payments = paymentsStore
result.tg = tgStore result.tg = tgStore
result.pending = pendingStore
result.outbox = outboxStore result.outbox = outboxStore
result.logger.Info("Payment gateway MongoDB storage initialised") result.logger.Info("Payment gateway MongoDB storage initialised")
return result, nil return result, nil
@@ -88,6 +95,10 @@ func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore
return r.tg return r.tg
} }
func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore {
return r.pending
}
func (r *Repository) Outbox() gatewayoutbox.Store { func (r *Repository) Outbox() gatewayoutbox.Store {
return r.outbox return r.outbox
} }

View File

@@ -0,0 +1,240 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/storage"
"github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.uber.org/zap"
)
const (
pendingConfirmationsCollection = "pending_confirmations"
fieldPendingRequestID = "requestId"
fieldPendingMessageID = "messageId"
fieldPendingExpiresAt = "expiresAt"
)
type PendingConfirmations struct {
logger mlogger.Logger
coll *mongo.Collection
}
func NewPendingConfirmations(logger mlogger.Logger, db *mongo.Database) (*PendingConfirmations, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("pending_confirmations").With(zap.String("collection", pendingConfirmationsCollection))
repo := repository.CreateMongoRepository(db, pendingConfirmationsCollection)
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: fieldPendingRequestID, Sort: ri.Asc}},
Unique: true,
}); err != nil {
logger.Error("Failed to create pending confirmations request_id index", zap.Error(err), zap.String("index_field", fieldPendingRequestID))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: fieldPendingMessageID, Sort: ri.Asc}},
}); err != nil {
logger.Error("Failed to create pending confirmations message_id index", zap.Error(err), zap.String("index_field", fieldPendingMessageID))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: fieldPendingExpiresAt, Sort: ri.Asc}},
}); err != nil {
logger.Error("Failed to create pending confirmations expires_at index", zap.Error(err), zap.String("index_field", fieldPendingExpiresAt))
return nil, err
}
p := &PendingConfirmations{
logger: logger,
coll: db.Collection(pendingConfirmationsCollection),
}
return p, nil
}
func (p *PendingConfirmations) Upsert(ctx context.Context, record *model.PendingConfirmation) error {
if record == nil {
return merrors.InvalidArgument("pending confirmation is nil", "record")
}
record.RequestID = strings.TrimSpace(record.RequestID)
record.MessageID = strings.TrimSpace(record.MessageID)
record.TargetChatID = strings.TrimSpace(record.TargetChatID)
record.SourceService = strings.TrimSpace(record.SourceService)
record.Rail = strings.TrimSpace(record.Rail)
if record.RequestID == "" {
return merrors.InvalidArgument("request_id is required", "request_id")
}
if record.TargetChatID == "" {
return merrors.InvalidArgument("target_chat_id is required", "target_chat_id")
}
if record.ExpiresAt.IsZero() {
return merrors.InvalidArgument("expires_at is required", "expires_at")
}
now := time.Now()
createdAt := record.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
record.UpdatedAt = now
record.CreatedAt = createdAt
record.ID = bson.NilObjectID
// Explicit map avoids accidentally overriding immutable fields from stale callers.
update := bson.M{
"$set": bson.M{
"messageId": record.MessageID,
"targetChatId": record.TargetChatID,
"acceptedUserIds": record.AcceptedUserIDs,
"requestedMoney": record.RequestedMoney,
"sourceService": record.SourceService,
"rail": record.Rail,
"clarified": record.Clarified,
"expiresAt": record.ExpiresAt,
"updatedAt": record.UpdatedAt,
},
"$setOnInsert": bson.M{
"createdAt": createdAt,
},
}
_, err := p.coll.UpdateOne(ctx, bson.M{fieldPendingRequestID: record.RequestID}, update, options.UpdateOne().SetUpsert(true))
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
p.logger.Warn("Failed to upsert pending confirmation", zap.Error(err), zap.String("request_id", record.RequestID))
}
return err
}
func (p *PendingConfirmations) FindByRequestID(ctx context.Context, requestID string) (*model.PendingConfirmation, error) {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
var result model.PendingConfirmation
err := p.coll.FindOne(ctx, bson.M{fieldPendingRequestID: requestID}).Decode(&result)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
return nil, err
}
return &result, nil
}
func (p *PendingConfirmations) FindByMessageID(ctx context.Context, messageID string) (*model.PendingConfirmation, error) {
messageID = strings.TrimSpace(messageID)
if messageID == "" {
return nil, merrors.InvalidArgument("message_id is required", "message_id")
}
var result model.PendingConfirmation
err := p.coll.FindOne(ctx, bson.M{fieldPendingMessageID: messageID}).Decode(&result)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
return nil, err
}
return &result, nil
}
func (p *PendingConfirmations) MarkClarified(ctx context.Context, requestID string) error {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return merrors.InvalidArgument("request_id is required", "request_id")
}
_, err := p.coll.UpdateOne(ctx, bson.M{fieldPendingRequestID: requestID}, bson.M{
"$set": bson.M{
"clarified": true,
"updatedAt": time.Now(),
},
})
return err
}
func (p *PendingConfirmations) AttachMessage(ctx context.Context, requestID string, messageID string) error {
requestID = strings.TrimSpace(requestID)
messageID = strings.TrimSpace(messageID)
if requestID == "" {
return merrors.InvalidArgument("request_id is required", "request_id")
}
if messageID == "" {
return merrors.InvalidArgument("message_id is required", "message_id")
}
filter := bson.M{
fieldPendingRequestID: requestID,
"$or": []bson.M{
{fieldPendingMessageID: bson.M{"$exists": false}},
{fieldPendingMessageID: ""},
{fieldPendingMessageID: messageID},
},
}
res, err := p.coll.UpdateOne(ctx, filter, bson.M{
"$set": bson.M{
fieldPendingMessageID: messageID,
"updatedAt": time.Now(),
},
})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return merrors.NoData("pending confirmation not found")
}
return nil
}
func (p *PendingConfirmations) DeleteByRequestID(ctx context.Context, requestID string) error {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return merrors.InvalidArgument("request_id is required", "request_id")
}
_, err := p.coll.DeleteOne(ctx, bson.M{fieldPendingRequestID: requestID})
return err
}
func (p *PendingConfirmations) ListExpired(ctx context.Context, now time.Time, limit int64) ([]*model.PendingConfirmation, error) {
if limit <= 0 {
limit = 100
}
filter := bson.M{
fieldPendingExpiresAt: bson.M{"$lte": now},
}
opts := options.Find().SetLimit(limit).SetSort(bson.D{{Key: fieldPendingExpiresAt, Value: 1}})
cursor, err := p.coll.Find(ctx, filter, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
result := make([]*model.PendingConfirmation, 0)
for cursor.Next(ctx) {
var next model.PendingConfirmation
if err := cursor.Decode(&next); err != nil {
return nil, err
}
result = append(result, &next)
}
if err := cursor.Err(); err != nil {
return nil, err
}
return result, nil
}
var _ storage.PendingConfirmationsStore = (*PendingConfirmations)(nil)

View File

@@ -2,6 +2,7 @@ package storage
import ( import (
"context" "context"
"time"
"github.com/tech/sendico/gateway/tgsettle/storage/model" "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
@@ -12,6 +13,7 @@ var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate reco
type Repository interface { type Repository interface {
Payments() PaymentsStore Payments() PaymentsStore
TelegramConfirmations() TelegramConfirmationsStore TelegramConfirmations() TelegramConfirmationsStore
PendingConfirmations() PendingConfirmationsStore
} }
type PaymentsStore interface { type PaymentsStore interface {
@@ -22,3 +24,13 @@ type PaymentsStore interface {
type TelegramConfirmationsStore interface { type TelegramConfirmationsStore interface {
Upsert(ctx context.Context, record *model.TelegramConfirmation) error Upsert(ctx context.Context, record *model.TelegramConfirmation) error
} }
type PendingConfirmationsStore interface {
Upsert(ctx context.Context, record *model.PendingConfirmation) error
FindByRequestID(ctx context.Context, requestID string) (*model.PendingConfirmation, error)
FindByMessageID(ctx context.Context, messageID string) (*model.PendingConfirmation, error)
MarkClarified(ctx context.Context, requestID string) error
AttachMessage(ctx context.Context, requestID string, messageID string) error
DeleteByRequestID(ctx context.Context, requestID string) error
ListExpired(ctx context.Context, now time.Time, limit int64) ([]*model.PendingConfirmation, error)
}

View File

@@ -1,593 +0,0 @@
package notificationimp
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
const (
defaultConfirmationTimeout = 120 * time.Second
)
type confirmationManager struct {
logger mlogger.Logger
tg telegram.Client
sender string
outbox msg.Producer
mu sync.Mutex
pendingByMessage map[string]*confirmationState
pendingByRequest map[string]*confirmationState
}
type confirmationState struct {
request model.ConfirmationRequest
requestMessageID string
targetChatID string
callbackSubject string
clarified bool
timer *time.Timer
}
func newConfirmationManager(logger mlogger.Logger, tg telegram.Client, outbox msg.Producer) *confirmationManager {
if logger != nil {
logger = logger.Named("confirmations")
}
return &confirmationManager{
logger: logger,
tg: tg,
outbox: outbox,
sender: string(mservice.Notifications),
pendingByMessage: map[string]*confirmationState{},
pendingByRequest: map[string]*confirmationState{},
}
}
func (m *confirmationManager) logDebug(message string, fields ...zap.Field) {
if m == nil || m.logger == nil {
return
}
m.logger.Debug(message, fields...)
}
func (m *confirmationManager) logInfo(message string, fields ...zap.Field) {
if m == nil || m.logger == nil {
return
}
m.logger.Info(message, fields...)
}
func (m *confirmationManager) logWarn(message string, fields ...zap.Field) {
if m == nil || m.logger == nil {
return
}
m.logger.Warn(message, fields...)
}
func (m *confirmationManager) Stop() {
if m == nil {
return
}
m.logInfo("Stopping confirmation manager")
m.mu.Lock()
defer m.mu.Unlock()
pending := len(m.pendingByMessage)
m.logDebug("Stopping pending confirmation timers", zap.Int("pending_count", pending))
for _, state := range m.pendingByMessage {
if state.timer != nil {
if !state.timer.Stop() {
m.logDebug("Confirmation timer already fired while stopping", zap.String("request_id", state.request.RequestID), zap.String("message_id", state.requestMessageID))
}
}
}
m.pendingByMessage = map[string]*confirmationState{}
m.pendingByRequest = map[string]*confirmationState{}
m.logInfo("Confirmation manager stopped", zap.Int("pending_cleared", pending))
}
func (m *confirmationManager) HandleRequest(ctx context.Context, request *model.ConfirmationRequest) error {
if m == nil {
return merrors.Internal("confirmation manager is nil")
}
m.logDebug("Handling confirmation request", zap.Bool("request_nil", request == nil))
if request == nil {
m.logWarn("Confirmation request rejected: request is nil")
return merrors.InvalidArgument("confirmation request is nil", "request")
}
if m.tg == nil {
m.logWarn("Confirmation request rejected: telegram client is not configured", zap.String("request_id", strings.TrimSpace(request.RequestID)))
return merrors.InvalidArgument("telegram client is not configured", "telegram")
}
req := normalizeConfirmationRequest(*request)
m.logDebug("Confirmation request normalized",
zap.String("request_id", req.RequestID),
zap.String("target_chat_id", req.TargetChatID),
zap.String("source_service", req.SourceService),
zap.String("rail", req.Rail),
zap.Int("accepted_users", len(req.AcceptedUserIDs)),
zap.Int32("timeout_seconds", req.TimeoutSeconds))
if req.RequestID == "" {
m.logWarn("Confirmation request rejected: request_id is required", zap.String("target_chat_id", req.TargetChatID))
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
}
if req.TargetChatID == "" {
m.logWarn("Confirmation request rejected: target_chat_id is required", zap.String("request_id", req.RequestID))
return merrors.InvalidArgument("confirmation target_chat_id is required", "target_chat_id")
}
if req.RequestedMoney == nil || strings.TrimSpace(req.RequestedMoney.Amount) == "" || strings.TrimSpace(req.RequestedMoney.Currency) == "" {
m.logWarn("Confirmation request rejected: requested_money is required", zap.String("request_id", req.RequestID))
return merrors.InvalidArgument("confirmation requested_money is required", "requested_money")
}
if req.SourceService == "" {
m.logWarn("Confirmation request rejected: source_service is required", zap.String("request_id", req.RequestID))
return merrors.InvalidArgument("confirmation source_service is required", "source_service")
}
m.mu.Lock()
pendingBefore := len(m.pendingByMessage)
if _, ok := m.pendingByRequest[req.RequestID]; ok {
m.mu.Unlock()
m.logInfo("Confirmation request already pending", zap.String("request_id", req.RequestID), zap.Int("pending_count", pendingBefore))
return nil
}
m.mu.Unlock()
m.logDebug("Confirmation request accepted for processing",
zap.String("request_id", req.RequestID),
zap.String("target_chat_id", req.TargetChatID),
zap.Int("pending_count_before", pendingBefore))
message := confirmationPrompt(&req)
m.logDebug("Sending confirmation request to Telegram",
zap.String("request_id", req.RequestID),
zap.String("target_chat_id", req.TargetChatID),
zap.Int("prompt_length", len(message)))
sent, err := m.tg.SendText(ctx, req.TargetChatID, message, "")
if err != nil {
m.logWarn("Failed to send confirmation request to Telegram", zap.Error(err), zap.String("request_id", req.RequestID), zap.String("target_chat_id", req.TargetChatID))
return err
}
if sent == nil || strings.TrimSpace(sent.MessageID) == "" {
m.logWarn("Confirmation request send succeeded without message_id", zap.String("request_id", req.RequestID))
return merrors.Internal("telegram confirmation message id is missing")
}
m.logDebug("Confirmation request sent to Telegram",
zap.String("request_id", req.RequestID),
zap.String("message_id", strings.TrimSpace(sent.MessageID)),
zap.String("target_chat_id", req.TargetChatID))
state := &confirmationState{
request: req,
requestMessageID: strings.TrimSpace(sent.MessageID),
targetChatID: strings.TrimSpace(req.TargetChatID),
callbackSubject: confirmationCallbackSubject(req.SourceService, req.Rail),
}
timeout := time.Duration(req.TimeoutSeconds) * time.Second
if timeout <= 0 {
m.logDebug("Confirmation timeout not provided, using default timeout", zap.String("request_id", req.RequestID), zap.Duration("timeout", defaultConfirmationTimeout))
timeout = defaultConfirmationTimeout
}
m.logDebug("Scheduling confirmation timeout",
zap.String("request_id", req.RequestID),
zap.String("message_id", state.requestMessageID),
zap.Duration("timeout", timeout))
state.timer = time.AfterFunc(timeout, func() {
m.handleTimeout(state.requestMessageID)
})
m.mu.Lock()
m.pendingByMessage[state.requestMessageID] = state
m.pendingByRequest[req.RequestID] = state
pendingAfter := len(m.pendingByMessage)
m.mu.Unlock()
m.logInfo("Confirmation request sent",
zap.String("request_id", req.RequestID),
zap.String("message_id", state.requestMessageID),
zap.String("callback_subject", state.callbackSubject),
zap.Int("pending_count", pendingAfter))
return nil
}
func (m *confirmationManager) HandleUpdate(ctx context.Context, update *telegram.Update) {
if m == nil {
return
}
if update == nil {
m.logInfo("Telegram update ignored: update is nil")
return
}
if update.Message == nil {
m.logInfo("Telegram update ignored: message is nil", zap.Int64("update_id", update.UpdateID))
return
}
message := update.Message
fields := []zap.Field{
zap.Int64("update_id", update.UpdateID),
zap.Int64("message_id", message.MessageID),
zap.Int64("chat_id", message.Chat.ID),
zap.Int("text_length", len(message.Text)),
}
if message.From != nil {
fields = append(fields, zap.Int64("from_user_id", message.From.ID))
}
if message.ReplyToMessage != nil {
fields = append(fields, zap.Int64("reply_to_message_id", message.ReplyToMessage.MessageID))
}
m.logInfo("Handling Telegram confirmation update", fields...)
if message.ReplyToMessage == nil {
m.logInfo("Telegram update ignored: message is not a reply", zap.Int64("message_id", message.MessageID))
return
}
replyToID := strconv.FormatInt(message.ReplyToMessage.MessageID, 10)
m.logDebug("Telegram reply received", zap.String("reply_to_message_id", replyToID))
state := m.lookupByMessageID(replyToID)
if state == nil {
m.logInfo("Telegram reply ignored: no pending confirmation for message", zap.String("reply_to_message_id", replyToID), zap.Int64("update_id", update.UpdateID))
return
}
m.logDebug("Telegram reply matched pending confirmation",
zap.String("request_id", state.request.RequestID),
zap.String("reply_to_message_id", replyToID))
chatID := strconv.FormatInt(message.Chat.ID, 10)
if chatID != state.targetChatID {
m.logInfo("Telegram reply ignored: chat mismatch",
zap.String("request_id", state.request.RequestID),
zap.String("expected_chat_id", state.targetChatID),
zap.String("chat_id", chatID))
return
}
rawReply := message.ToModel()
if !state.isUserAllowed(message.From) {
userID := ""
if message.From != nil {
userID = strconv.FormatInt(message.From.ID, 10)
}
m.logWarn("Telegram reply rejected: unauthorized user",
zap.String("request_id", state.request.RequestID),
zap.String("user_id", userID),
zap.String("chat_id", chatID))
m.publishResult(state, &model.ConfirmationResult{
RequestID: state.request.RequestID,
Status: model.ConfirmationStatusRejected,
ParseError: "unauthorized_user",
RawReply: rawReply,
})
m.sendNotice(ctx, state, rawReply, "Only approved users can confirm this payment.")
m.removeState(state.requestMessageID)
return
}
m.logDebug("Telegram reply accepted from authorized user", zap.String("request_id", state.request.RequestID))
money, reason, err := parseConfirmationReply(message.Text)
if err != nil {
m.logInfo("Telegram reply requires clarification",
zap.String("request_id", state.request.RequestID),
zap.String("reason", reason),
zap.Error(err))
m.mu.Lock()
state.clarified = true
m.mu.Unlock()
m.sendNotice(ctx, state, rawReply, clarificationMessage(reason))
return
}
m.mu.Lock()
clarified := state.clarified
m.mu.Unlock()
status := model.ConfirmationStatusConfirmed
if clarified {
status = model.ConfirmationStatusClarified
}
m.logInfo("Telegram confirmation parsed",
zap.String("request_id", state.request.RequestID),
zap.String("status", string(status)),
zap.String("amount", money.Amount),
zap.String("currency", money.Currency))
m.publishResult(state, &model.ConfirmationResult{
RequestID: state.request.RequestID,
Money: money,
RawReply: rawReply,
Status: status,
})
m.removeState(state.requestMessageID)
}
func (m *confirmationManager) lookupByMessageID(messageID string) *confirmationState {
if m == nil {
return nil
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
m.logDebug("Pending confirmation lookup skipped: message_id is empty")
return nil
}
m.mu.Lock()
state := m.pendingByMessage[messageID]
pendingCount := len(m.pendingByMessage)
m.mu.Unlock()
if state == nil {
m.logDebug("Pending confirmation not found", zap.String("message_id", messageID), zap.Int("pending_count", pendingCount))
return nil
}
m.logDebug("Pending confirmation found",
zap.String("message_id", messageID),
zap.String("request_id", state.request.RequestID),
zap.Int("pending_count", pendingCount))
return state
}
func (m *confirmationManager) handleTimeout(messageID string) {
if m == nil {
return
}
messageID = strings.TrimSpace(messageID)
m.logInfo("Confirmation timeout triggered", zap.String("message_id", messageID))
state := m.lookupByMessageID(messageID)
if state == nil {
m.logDebug("Confirmation timeout ignored: state not found", zap.String("message_id", messageID))
return
}
m.logInfo("Publishing timeout confirmation result",
zap.String("request_id", state.request.RequestID),
zap.String("message_id", messageID))
m.publishResult(state, &model.ConfirmationResult{
RequestID: state.request.RequestID,
Status: model.ConfirmationStatusTimeout,
})
m.removeState(messageID)
}
func (m *confirmationManager) removeState(messageID string) {
if m == nil {
return
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
m.logDebug("State removal skipped: message_id is empty")
return
}
m.mu.Lock()
state := m.pendingByMessage[messageID]
if state != nil && state.timer != nil {
if !state.timer.Stop() {
m.logDebug("Confirmation timer already fired before state removal", zap.String("message_id", messageID), zap.String("request_id", state.request.RequestID))
}
}
delete(m.pendingByMessage, messageID)
if state != nil {
delete(m.pendingByRequest, state.request.RequestID)
}
pendingCount := len(m.pendingByMessage)
m.mu.Unlock()
if state == nil {
m.logDebug("State removal skipped: no state for message", zap.String("message_id", messageID), zap.Int("pending_count", pendingCount))
return
}
m.logInfo("Confirmation state removed",
zap.String("message_id", messageID),
zap.String("request_id", state.request.RequestID),
zap.Int("pending_count", pendingCount))
}
func (m *confirmationManager) publishResult(state *confirmationState, result *model.ConfirmationResult) {
if m == nil || state == nil || result == nil {
m.logDebug("Confirmation result publish skipped: missing context",
zap.Bool("state_nil", state == nil),
zap.Bool("result_nil", result == nil))
return
}
m.logDebug("Publishing confirmation result",
zap.String("request_id", state.request.RequestID),
zap.String("status", string(result.Status)),
zap.String("callback_subject", state.callbackSubject))
if m.outbox == nil {
m.logWarn("Confirmation result skipped: producer not configured",
zap.String("request_id", state.request.RequestID),
zap.String("callback_subject", state.callbackSubject))
return
}
env := confirmations.ConfirmationResult(m.sender, result, state.request.SourceService, state.request.Rail)
m.logDebug("Confirmation result envelope prepared",
zap.String("request_id", state.request.RequestID),
zap.String("sender", m.sender),
zap.String("source_service", state.request.SourceService),
zap.String("rail", state.request.Rail))
if err := m.outbox.SendMessage(env); err != nil {
m.logWarn("Failed to publish confirmation result", zap.Error(err), zap.String("request_id", state.request.RequestID), zap.String("callback_subject", state.callbackSubject))
return
}
m.logInfo("Confirmation result published",
zap.String("request_id", state.request.RequestID),
zap.String("status", string(result.Status)),
zap.String("callback_subject", state.callbackSubject))
}
func (m *confirmationManager) sendNotice(ctx context.Context, state *confirmationState, reply *model.TelegramMessage, text string) {
if m == nil {
return
}
if m.tg == nil {
m.logWarn("Clarification notice skipped: telegram client is not configured")
return
}
if state == nil {
m.logDebug("Clarification notice skipped: state is nil")
return
}
replyID := ""
if reply != nil {
replyID = reply.MessageID
}
m.logDebug("Sending clarification notice",
zap.String("request_id", state.request.RequestID),
zap.String("target_chat_id", state.targetChatID),
zap.String("reply_to_message_id", replyID),
zap.Int("text_length", len(text)))
if _, err := m.tg.SendText(ctx, state.targetChatID, text, replyID); err != nil {
m.logWarn("Failed to send clarification notice", zap.Error(err), zap.String("request_id", state.request.RequestID))
return
}
m.logInfo("Clarification notice sent", zap.String("request_id", state.request.RequestID), zap.String("target_chat_id", state.targetChatID))
}
func (s *confirmationState) isUserAllowed(user *telegram.User) bool {
if s == nil {
return false
}
allowed := s.request.AcceptedUserIDs
if len(allowed) == 0 {
return true
}
if user == nil {
return false
}
userID := strconv.FormatInt(user.ID, 10)
for _, id := range allowed {
if id == userID {
return true
}
}
return false
}
func confirmationCallbackSubject(sourceService, rail string) string {
sourceService = strings.ToLower(strings.TrimSpace(sourceService))
if sourceService == "" {
sourceService = "unknown"
}
rail = strings.ToLower(strings.TrimSpace(rail))
if rail == "" {
rail = "default"
}
return "confirmations." + sourceService + "." + rail
}
func normalizeConfirmationRequest(request model.ConfirmationRequest) model.ConfirmationRequest {
request.RequestID = strings.TrimSpace(request.RequestID)
request.TargetChatID = strings.TrimSpace(request.TargetChatID)
request.PaymentIntentID = strings.TrimSpace(request.PaymentIntentID)
request.QuoteRef = strings.TrimSpace(request.QuoteRef)
request.SourceService = strings.TrimSpace(request.SourceService)
request.Rail = strings.TrimSpace(request.Rail)
request.AcceptedUserIDs = normalizeStringList(request.AcceptedUserIDs)
if request.RequestedMoney != nil {
request.RequestedMoney.Amount = strings.TrimSpace(request.RequestedMoney.Amount)
request.RequestedMoney.Currency = strings.TrimSpace(request.RequestedMoney.Currency)
}
return request
}
func normalizeStringList(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
if len(result) == 0 {
return nil
}
return result
}
var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`)
func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, "empty", merrors.InvalidArgument("empty reply")
}
parts := strings.Fields(text)
if len(parts) < 2 {
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
}
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
}
if len(parts) > 2 {
return nil, "format", merrors.InvalidArgument("reply format is invalid")
}
amount := parts[0]
currency := parts[1]
if !amountPattern.MatchString(amount) {
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
}
if !currencyPattern.MatchString(currency) {
return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
}
return &paymenttypes.Money{
Amount: amount,
Currency: strings.ToUpper(currency),
}, "", nil
}
func confirmationPrompt(req *model.ConfirmationRequest) string {
var builder strings.Builder
builder.WriteString("Payment confirmation required\n")
if req.QuoteRef != "" {
builder.WriteString("Quote ref: ")
builder.WriteString(req.QuoteRef)
builder.WriteString("\n")
}
if req.RequestedMoney != nil {
amountFloat, err := strconv.ParseFloat(req.RequestedMoney.Amount, 64)
if err != nil {
amountFloat = 0
}
amount := fmt.Sprintf("%.2f", amountFloat)
builder.WriteString(fmt.Sprintf(
"\n*Requested: %s %s*\n\n",
amount,
req.RequestedMoney.Currency,
))
}
builder.WriteString("Reply with \"<amount> <currency>\" (e.g., 12.34 USD).")
return builder.String()
}
func clarificationMessage(reason string) string {
switch reason {
case "missing_currency":
return "Currency code is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "missing_amount":
return "Amount is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "invalid_amount":
return "Amount must be a decimal number. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
case "invalid_currency":
return "Currency must be a code like USD or EUR. Reply with \"<amount> <currency>\"."
default:
return "Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
}
}

View File

@@ -0,0 +1,150 @@
package notificationimp
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/tech/sendico/pkg/merrors"
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func (a *NotificationAPI) onConfirmationRequest(ctx context.Context, request *model.ConfirmationRequest) error {
if request == nil {
return merrors.InvalidArgument("confirmation request is nil", "request")
}
if a == nil || a.tg == nil {
return merrors.Internal("telegram client is not configured")
}
req := normalizeConfirmationRequest(*request)
if req.RequestID == "" {
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
}
if req.TargetChatID == "" {
return merrors.InvalidArgument("confirmation target_chat_id is required", "target_chat_id")
}
if req.RequestedMoney == nil || strings.TrimSpace(req.RequestedMoney.Amount) == "" || strings.TrimSpace(req.RequestedMoney.Currency) == "" {
return merrors.InvalidArgument("confirmation requested_money is required", "requested_money")
}
if req.SourceService == "" {
return merrors.InvalidArgument("confirmation source_service is required", "source_service")
}
prompt := confirmationPrompt(&req)
sent, err := a.tg.SendText(ctx, req.TargetChatID, prompt, "")
if err != nil {
a.logger.Warn("Failed to send confirmation prompt to Telegram", zap.Error(err), zap.String("request_id", req.RequestID), zap.String("chat_id", req.TargetChatID))
return err
}
if sent == nil || strings.TrimSpace(sent.MessageID) == "" {
return merrors.Internal("telegram confirmation message id is missing")
}
a.logger.Info("Telegram confirmation prompt sent",
zap.String("request_id", req.RequestID),
zap.String("chat_id", req.TargetChatID),
zap.String("message_id", strings.TrimSpace(sent.MessageID)))
if a.producer == nil {
return merrors.Internal("messaging producer is not configured")
}
dispatch := &model.ConfirmationRequestDispatch{
RequestID: req.RequestID,
ChatID: req.TargetChatID,
MessageID: strings.TrimSpace(sent.MessageID),
SourceService: req.SourceService,
Rail: req.Rail,
}
env := confirmations.ConfirmationDispatch(string(mservice.Notifications), dispatch, req.SourceService, req.Rail)
if err := a.producer.SendMessage(env); err != nil {
a.logger.Warn("Failed to publish confirmation dispatch", zap.Error(err), zap.String("request_id", req.RequestID), zap.String("message_id", dispatch.MessageID))
return err
}
return nil
}
func (a *NotificationAPI) onTelegramText(ctx context.Context, request *model.TelegramTextRequest) error {
if request == nil {
return merrors.InvalidArgument("telegram text request is nil", "request")
}
if a == nil || a.tg == nil {
return merrors.Internal("telegram client is not configured")
}
request.ChatID = strings.TrimSpace(request.ChatID)
request.ReplyToMessageID = strings.TrimSpace(request.ReplyToMessageID)
request.Text = strings.TrimSpace(request.Text)
if request.ChatID == "" {
return merrors.InvalidArgument("telegram chat_id is required", "chat_id")
}
if request.Text == "" {
return merrors.InvalidArgument("telegram text is required", "text")
}
if _, err := a.tg.SendText(ctx, request.ChatID, request.Text, request.ReplyToMessageID); err != nil {
a.logger.Warn("Failed to send telegram text", zap.Error(err),
zap.String("request_id", request.RequestID),
zap.String("chat_id", request.ChatID),
zap.String("reply_to_message_id", request.ReplyToMessageID))
return err
}
return nil
}
func normalizeConfirmationRequest(request model.ConfirmationRequest) model.ConfirmationRequest {
request.RequestID = strings.TrimSpace(request.RequestID)
request.TargetChatID = strings.TrimSpace(request.TargetChatID)
request.PaymentIntentID = strings.TrimSpace(request.PaymentIntentID)
request.QuoteRef = strings.TrimSpace(request.QuoteRef)
request.SourceService = strings.TrimSpace(request.SourceService)
request.Rail = strings.TrimSpace(request.Rail)
request.AcceptedUserIDs = normalizeStringList(request.AcceptedUserIDs)
if request.RequestedMoney != nil {
request.RequestedMoney.Amount = strings.TrimSpace(request.RequestedMoney.Amount)
request.RequestedMoney.Currency = strings.TrimSpace(request.RequestedMoney.Currency)
}
return request
}
func normalizeStringList(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
if len(result) == 0 {
return nil
}
return result
}
func confirmationPrompt(req *model.ConfirmationRequest) string {
var builder strings.Builder
builder.WriteString("Payment confirmation required\n")
if req.QuoteRef != "" {
builder.WriteString("Quote ref: ")
builder.WriteString(req.QuoteRef)
builder.WriteString("\n")
}
if req.RequestedMoney != nil {
amountFloat, err := strconv.ParseFloat(req.RequestedMoney.Amount, 64)
if err != nil {
amountFloat = 0
}
builder.WriteString(fmt.Sprintf("\n*Requested: %.2f %s*\n\n", amountFloat, req.RequestedMoney.Currency))
}
builder.WriteString("Reply with \"<amount> <currency>\" (e.g., 12.34 USD).")
return builder.String()
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/domainprovider" "github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
na "github.com/tech/sendico/pkg/messaging/notifications/account" na "github.com/tech/sendico/pkg/messaging/notifications/account"
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation" cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
@@ -27,8 +28,8 @@ type NotificationAPI struct {
client mmail.Client client mmail.Client
dp domainprovider.DomainProvider dp domainprovider.DomainProvider
tg telegram.Client tg telegram.Client
producer msg.Producer
announcer *discovery.Announcer announcer *discovery.Announcer
confirm *confirmationManager
} }
func (a *NotificationAPI) Name() mservice.Type { func (a *NotificationAPI) Name() mservice.Type {
@@ -39,9 +40,6 @@ func (a *NotificationAPI) Finish(_ context.Context) error {
if a.announcer != nil { if a.announcer != nil {
a.announcer.Stop() a.announcer.Stop()
} }
if a.confirm != nil {
a.confirm.Stop()
}
return nil return nil
} }
@@ -50,6 +48,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
dp: a.DomainProvider(), dp: a.DomainProvider(),
} }
p.logger = a.Logger().Named(p.Name()) p.logger = a.Logger().Named(p.Name())
p.producer = a.Register().Producer()
if a.Config().Notification == nil { if a.Config().Notification == nil {
return nil, merrors.InvalidArgument("notification configuration is missing", "config.notification") return nil, merrors.InvalidArgument("notification configuration is missing", "config.notification")
@@ -67,7 +66,6 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
p.logger.Error("Failed to create telegram client", zap.Error(err)) p.logger.Error("Failed to create telegram client", zap.Error(err))
return nil, err return nil, err
} }
p.confirm = newConfirmationManager(p.logger, p.tg, a.Register().Producer())
db, err := a.DBFactory().NewAccountDB() db, err := a.DBFactory().NewAccountDB()
if err != nil { if err != nil {
@@ -92,6 +90,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
p.logger.Error("Failed to register confirmation request handler", zap.Error(err)) p.logger.Error("Failed to register confirmation request handler", zap.Error(err))
return nil, err return nil, err
} }
if err := a.Register().Consumer(tnotifications.NewTelegramTextProcessor(p.logger, p.onTelegramText)); err != nil {
p.logger.Error("Failed to register telegram text handler", zap.Error(err))
return nil, err
}
if err := a.Register().Consumer(tnotifications.NewTelegramReactionProcessor(p.logger, p.onTelegramReaction)); err != nil { if err := a.Register().Consumer(tnotifications.NewTelegramReactionProcessor(p.logger, p.onTelegramReaction)); err != nil {
p.logger.Error("Failed to register telegram reaction handler", zap.Error(err)) p.logger.Error("Failed to register telegram reaction handler", zap.Error(err))
return nil, err return nil, err
@@ -162,10 +164,3 @@ func (a *NotificationAPI) onCallRequest(ctx context.Context, request *model.Call
a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone)) a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone))
return nil return nil
} }
func (a *NotificationAPI) onConfirmationRequest(ctx context.Context, request *model.ConfirmationRequest) error {
if a.confirm == nil {
return merrors.Internal("confirmation manager is not configured")
}
return a.confirm.HandleRequest(ctx, request)
}

View File

@@ -6,6 +6,9 @@ import (
"net/http" "net/http"
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram" "github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -16,11 +19,11 @@ func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
if a.confirm == nil { if a.producer == nil {
if a.logger != nil { if a.logger != nil {
a.logger.Warn("Telegram webhook ignored: confirmation manager is not configured") a.logger.Warn("Telegram webhook ignored: messaging producer is not configured")
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusServiceUnavailable)
return return
} }
var update telegram.Update var update telegram.Update
@@ -52,6 +55,19 @@ func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.R
} }
a.logger.Info("Telegram webhook update received", fields...) a.logger.Info("Telegram webhook update received", fields...)
} }
a.confirm.HandleUpdate(r.Context(), &update) payload := &model.TelegramWebhookUpdate{
UpdateID: update.UpdateID,
}
if update.Message != nil {
payload.Message = update.Message.ToModel()
}
env := tnotifications.TelegramUpdate(string(mservice.Notifications), payload)
if err := a.producer.SendMessage(env); err != nil {
if a.logger != nil {
a.logger.Warn("Failed to publish telegram webhook update", zap.Error(err), zap.Int64("update_id", update.UpdateID))
}
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }

View File

@@ -0,0 +1,101 @@
package agg
import (
"time"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/db/storable"
pm "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Factory builds initial orchestration-v2 payment aggregates.
type Factory interface {
Create(in Input) (*Payment, error)
}
// State is orchestration runtime state.
type State string
const (
StateUnspecified State = "unspecified"
StateCreated State = "created"
StateExecuting State = "executing"
StateNeedsAttention State = "needs_attention"
StateSettled State = "settled"
StateFailed State = "failed"
)
// StepState is step-level execution state.
type StepState string
const (
StepStateUnspecified StepState = "unspecified"
StepStatePending StepState = "pending"
StepStateRunning StepState = "running"
StepStateCompleted StepState = "completed"
StepStateFailed StepState = "failed"
StepStateNeedsAttention StepState = "needs_attention"
StepStateSkipped StepState = "skipped"
)
// StepShell defines one initial step telemetry item.
type StepShell struct {
StepRef string
StepCode string
}
// StepExecution is runtime telemetry for one step.
type StepExecution struct {
StepRef string
StepCode string
State StepState
Attempt uint32
StartedAt *time.Time
CompletedAt *time.Time
FailureCode string
FailureMsg string
ExternalRefs []ExternalRef
}
// ExternalRef links step execution to an external operation.
type ExternalRef struct {
GatewayInstanceID string
Kind string
Ref string
}
// Input defines payload for creating an initial payment aggregate.
type Input struct {
OrganizationRef bson.ObjectID
IdempotencyKey string
QuotationRef string
ClientPaymentRef string
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
Steps []StepShell
}
// Payment is orchestration-v2 runtime aggregate.
type Payment struct {
storable.Base
pm.OrganizationBoundBase
PaymentRef string
IdempotencyKey string
QuotationRef string
ClientPaymentRef string
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
State State
Version uint64
StepExecutions []StepExecution
}
func New() Factory {
return &svc{
now: func() time.Time { return time.Now().UTC() },
newID: func() bson.ObjectID {
return bson.NewObjectID()
},
}
}

View File

@@ -0,0 +1,166 @@
package agg
import (
"strings"
"time"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
pm "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
const initialVersion uint64 = 1
type svc struct {
now func() time.Time
newID func() bson.ObjectID
}
func (s *svc) Create(in Input) (*Payment, error) {
if in.OrganizationRef.IsZero() {
return nil, merrors.InvalidArgument("organization_id is required")
}
idempotencyKey := strings.TrimSpace(in.IdempotencyKey)
if idempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
quotationRef := strings.TrimSpace(in.QuotationRef)
if quotationRef == "" {
return nil, merrors.InvalidArgument("quotation_ref is required")
}
if isEmptyIntentSnapshot(in.IntentSnapshot) {
return nil, merrors.InvalidArgument("intent_snapshot is required")
}
if in.QuoteSnapshot == nil {
return nil, merrors.InvalidArgument("quote_snapshot is required")
}
intentSnapshot, err := cloneIntentSnapshot(in.IntentSnapshot)
if err != nil {
return nil, err
}
quoteSnapshot, err := cloneQuoteSnapshot(in.QuoteSnapshot)
if err != nil {
return nil, err
}
if quoteSnapshot == nil {
return nil, merrors.InvalidArgument("quote_snapshot is required")
}
if quoteRef := strings.TrimSpace(quoteSnapshot.QuoteRef); quoteRef == "" {
quoteSnapshot.QuoteRef = quotationRef
} else if quoteRef != quotationRef {
return nil, merrors.InvalidArgument("quote_snapshot.quote_ref must match quotation_ref")
}
stepExecutions, err := buildInitialStepTelemetry(in.Steps)
if err != nil {
return nil, err
}
now := s.now().UTC()
id := s.newID()
return &Payment{
Base: storable.Base{
ID: id,
CreatedAt: now,
UpdatedAt: now,
},
OrganizationBoundBase: pm.OrganizationBoundBase{
OrganizationRef: in.OrganizationRef,
},
PaymentRef: id.Hex(),
IdempotencyKey: idempotencyKey,
QuotationRef: quotationRef,
ClientPaymentRef: strings.TrimSpace(in.ClientPaymentRef),
IntentSnapshot: intentSnapshot,
QuoteSnapshot: quoteSnapshot,
State: StateCreated,
Version: initialVersion,
StepExecutions: stepExecutions,
}, nil
}
func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
if len(shell) == 0 {
return nil, nil
}
seenRefs := make(map[string]struct{}, len(shell))
out := make([]StepExecution, 0, len(shell))
for i := range shell {
stepRef := strings.TrimSpace(shell[i].StepRef)
if stepRef == "" {
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_ref is required")
}
if _, exists := seenRefs[stepRef]; exists {
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_ref must be unique")
}
seenRefs[stepRef] = struct{}{}
stepCode := strings.TrimSpace(shell[i].StepCode)
if stepCode == "" {
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_code is required")
}
out = append(out, StepExecution{
StepRef: stepRef,
StepCode: stepCode,
State: StepStatePending,
Attempt: 1,
})
}
return out, nil
}
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
var dst model.PaymentIntent
if err := bsonClone(src, &dst); err != nil {
return model.PaymentIntent{}, err
}
return dst, nil
}
func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSnapshot, error) {
if src == nil {
return nil, nil
}
dst := &model.PaymentQuoteSnapshot{}
if err := bsonClone(src, dst); err != nil {
return nil, err
}
return dst, nil
}
func bsonClone(src any, dst any) error {
data, err := bson.Marshal(src)
if err != nil {
return err
}
return bson.Unmarshal(data, dst)
}
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
}
func itoa(v int) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}

View File

@@ -0,0 +1,267 @@
package agg
import (
"errors"
"testing"
"time"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestCreate_OK(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
orgID := bson.NewObjectID()
paymentID := bson.NewObjectID()
factory := &svc{
now: func() time.Time { return now },
newID: func() bson.ObjectID {
return paymentID
},
}
intent := model.PaymentIntent{
Ref: "intent-1",
Kind: model.PaymentKindPayout,
}
quote := &model.PaymentQuoteSnapshot{
QuoteRef: "",
}
payment, err := factory.Create(Input{
OrganizationRef: orgID,
IdempotencyKey: " idem-1 ",
QuotationRef: " quote-1 ",
ClientPaymentRef: " client-1 ",
IntentSnapshot: intent,
QuoteSnapshot: quote,
Steps: []StepShell{
{StepRef: " s1 ", StepCode: " reserve_funds "},
{StepRef: "s2", StepCode: "submit_gateway"},
},
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if payment == nil {
t.Fatal("expected aggregate")
}
if got, want := payment.ID, paymentID; got != want {
t.Fatalf("id mismatch: got=%s want=%s", got.Hex(), want.Hex())
}
if got, want := payment.PaymentRef, paymentID.Hex(); got != want {
t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want)
}
if got, want := payment.OrganizationRef, orgID; got != want {
t.Fatalf("organization mismatch: got=%s want=%s", got.Hex(), want.Hex())
}
if got, want := payment.IdempotencyKey, "idem-1"; got != want {
t.Fatalf("idempotency_key mismatch: got=%q want=%q", got, want)
}
if got, want := payment.QuotationRef, "quote-1"; got != want {
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := payment.ClientPaymentRef, "client-1"; got != want {
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
}
if got, want := payment.State, StateCreated; got != want {
t.Fatalf("state mismatch: got=%q want=%q", got, want)
}
if got, want := payment.Version, initialVersion; got != want {
t.Fatalf("version mismatch: got=%d want=%d", got, want)
}
if got, want := payment.CreatedAt, now; got != want {
t.Fatalf("created_at mismatch: got=%v want=%v", got, want)
}
if got, want := payment.UpdatedAt, now; got != want {
t.Fatalf("updated_at mismatch: got=%v want=%v", got, want)
}
if got, want := payment.IntentSnapshot.Ref, "intent-1"; got != want {
t.Fatalf("intent_snapshot.ref mismatch: got=%q want=%q", got, want)
}
if payment.QuoteSnapshot == nil {
t.Fatal("expected quote_snapshot")
}
if got, want := payment.QuoteSnapshot.QuoteRef, "quote-1"; got != want {
t.Fatalf("quote_snapshot.quote_ref mismatch: got=%q want=%q", got, want)
}
if len(payment.StepExecutions) != 2 {
t.Fatalf("expected 2 step executions, got %d", len(payment.StepExecutions))
}
if payment.StepExecutions[0].StepRef != "s1" || payment.StepExecutions[0].StepCode != "reserve_funds" {
t.Fatalf("unexpected first step: %+v", payment.StepExecutions[0])
}
if payment.StepExecutions[0].State != StepStatePending || payment.StepExecutions[0].Attempt != 1 {
t.Fatalf("unexpected first step shell state: %+v", payment.StepExecutions[0])
}
// Verify immutable snapshot semantics by ensuring clones were created.
payment.IntentSnapshot.Ref = "changed"
payment.QuoteSnapshot.QuoteRef = "changed"
if intent.Ref != "intent-1" {
t.Fatalf("expected original intent unchanged, got %q", intent.Ref)
}
if quote.QuoteRef != "" {
t.Fatalf("expected original quote unchanged, got %q", quote.QuoteRef)
}
}
func TestCreate_QuoteRefMismatch(t *testing.T) {
factory := New()
_, err := factory.Create(Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Amount: testMoney(),
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
QuoteRef: "quote-2",
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument, got %v", err)
}
}
func TestCreate_NoStepsProducesEmptyShell(t *testing.T) {
factory := New()
payment, err := factory.Create(Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Amount: testMoney(),
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
QuoteRef: "quote-1",
},
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if len(payment.StepExecutions) != 0 {
t.Fatalf("expected empty step telemetry shell, got %d", len(payment.StepExecutions))
}
}
func TestCreate_InputValidation(t *testing.T) {
tests := []struct {
name string
in Input
}{
{
name: "missing organization_id",
in: Input{
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
},
},
{
name: "missing idempotency_key",
in: Input{
OrganizationRef: bson.NewObjectID(),
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
},
},
{
name: "missing quotation_ref",
in: Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
},
},
{
name: "missing intent_snapshot",
in: Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
},
},
{
name: "missing quote_snapshot",
in: Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
},
},
{
name: "step missing ref",
in: Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
Steps: []StepShell{
{StepRef: " ", StepCode: "code"},
},
},
},
{
name: "step missing code",
in: Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
Steps: []StepShell{
{StepRef: "s1", StepCode: " "},
},
},
},
{
name: "step ref must be unique",
in: Input{
OrganizationRef: bson.NewObjectID(),
IdempotencyKey: "idem-1",
QuotationRef: "quote-1",
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
Steps: []StepShell{
{StepRef: "s1", StepCode: "code-1"},
{StepRef: "s1", StepCode: "code-2"},
},
},
},
}
factory := New()
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
_, err := factory.Create(tt.in)
if err == nil {
t.Fatal("expected error")
}
})
}
}
func testMoney() *modelMoney {
return &modelMoney{Amount: "10", Currency: "USD"}
}
// modelMoney is a minimal compatibility shim for tests without depending on payments/types constructors.
type modelMoney = paymenttypes.Money

View File

@@ -0,0 +1,7 @@
package idem
import "errors"
var (
ErrIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
)

View File

@@ -0,0 +1,36 @@
package idem
import (
"crypto/sha256"
"encoding/hex"
"strings"
"github.com/tech/sendico/pkg/merrors"
)
const hashSep = "\x1f"
func (s *svc) Fingerprint(in FPInput) (string, error) {
orgRef := strings.ToLower(strings.TrimSpace(in.OrganizationRef))
if orgRef == "" {
return "", merrors.InvalidArgument("organization_ref is required")
}
quotationRef := strings.TrimSpace(in.QuotationRef)
if quotationRef == "" {
return "", merrors.InvalidArgument("quotation_ref is required")
}
clientPaymentRef := strings.TrimSpace(in.ClientPaymentRef)
payload := strings.Join([]string{
"org=" + orgRef,
"quote=" + quotationRef,
"client=" + clientPaymentRef,
}, hashSep)
return hashBytes([]byte(payload)), nil
}
func hashBytes(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,45 @@
package idem
import (
"context"
"github.com/tech/sendico/payments/storage/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Store is the minimal payment store contract required for idempotency handling.
type Store interface {
Create(ctx context.Context, payment *model.Payment) error
GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
}
// Service handles execute-payment idempotency concerns for v2 orchestrator flow.
type Service interface {
Fingerprint(in FPInput) (string, error)
TryReuse(ctx context.Context, store Store, in ReuseInput) (*model.Payment, bool, error)
CreateOrReuse(ctx context.Context, store Store, in CreateInput) (*model.Payment, bool, error)
}
// FPInput is the business payload used for idempotency fingerprinting.
type FPInput struct {
OrganizationRef string
QuotationRef string
ClientPaymentRef string
}
// ReuseInput defines lookup and comparison inputs for idempotency reuse checks.
type ReuseInput struct {
OrganizationID bson.ObjectID
IdempotencyKey string
Fingerprint string
}
// CreateInput wraps create operation with reuse-check context for duplicate races.
type CreateInput struct {
Payment *model.Payment
Reuse ReuseInput
}
func New() Service {
return &svc{}
}

View File

@@ -0,0 +1,122 @@
package idem
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
const reqHashMetaKey = "_orchestrator_v2_req_hash"
type svc struct{}
func (s *svc) TryReuse(
ctx context.Context,
store Store,
in ReuseInput,
) (*model.Payment, bool, error) {
if store == nil {
return nil, false, merrors.InvalidArgument("payments store is required")
}
idempotencyKey, fingerprint, err := validateReuseInput(in)
if err != nil {
return nil, false, err
}
payment, err := store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey)
if err != nil {
if errors.Is(err, storage.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) {
return nil, false, nil
}
return nil, false, err
}
if payment == nil {
return nil, false, nil
}
if paymentReqHash(payment) != fingerprint {
return nil, false, ErrIdempotencyParamMismatch
}
return payment, true, nil
}
func (s *svc) CreateOrReuse(
ctx context.Context,
store Store,
in CreateInput,
) (*model.Payment, bool, error) {
if store == nil {
return nil, false, merrors.InvalidArgument("payments store is required")
}
if in.Payment == nil {
return nil, false, merrors.InvalidArgument("payment is required")
}
_, fingerprint, err := validateReuseInput(in.Reuse)
if err != nil {
return nil, false, err
}
setPaymentReqHash(in.Payment, fingerprint)
if err := store.Create(ctx, in.Payment); err != nil {
if !errors.Is(err, storage.ErrDuplicatePayment) {
return nil, false, err
}
payment, reused, reuseErr := s.TryReuse(ctx, store, in.Reuse)
if reuseErr != nil {
return nil, false, reuseErr
}
if reused {
return payment, true, nil
}
return nil, false, err
}
return in.Payment, false, nil
}
func validateReuseInput(in ReuseInput) (string, string, error) {
if in.OrganizationID.IsZero() {
return "", "", merrors.InvalidArgument("organization_id is required")
}
idempotencyKey := strings.TrimSpace(in.IdempotencyKey)
if idempotencyKey == "" {
return "", "", merrors.InvalidArgument("idempotency_key is required")
}
fingerprint := strings.TrimSpace(in.Fingerprint)
if fingerprint == "" {
return "", "", merrors.InvalidArgument("fingerprint is required")
}
return idempotencyKey, fingerprint, nil
}
func paymentReqHash(payment *model.Payment) string {
if payment == nil || payment.Metadata == nil {
return ""
}
return strings.TrimSpace(payment.Metadata[reqHashMetaKey])
}
func setPaymentReqHash(payment *model.Payment, hash string) {
if payment == nil {
return
}
hash = strings.TrimSpace(hash)
if hash == "" {
return
}
if payment.Metadata == nil {
payment.Metadata = map[string]string{}
}
payment.Metadata[reqHashMetaKey] = hash
}

View File

@@ -0,0 +1,297 @@
package idem
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestFingerprint_StableAndTrimmed(t *testing.T) {
svc := New()
a, err := svc.Fingerprint(FPInput{
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
QuotationRef: " quote-1 ",
ClientPaymentRef: " client-1 ",
})
if err != nil {
t.Fatalf("Fingerprint returned error: %v", err)
}
b, err := svc.Fingerprint(FPInput{
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
QuotationRef: "quote-1",
ClientPaymentRef: "client-1",
})
if err != nil {
t.Fatalf("Fingerprint returned error: %v", err)
}
if a != b {
t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b)
}
}
func TestFingerprint_ChangesOnPayload(t *testing.T) {
svc := New()
base, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-1",
ClientPaymentRef: "client-1",
})
if err != nil {
t.Fatalf("Fingerprint returned error: %v", err)
}
diffQuote, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-2",
ClientPaymentRef: "client-1",
})
if err != nil {
t.Fatalf("Fingerprint returned error: %v", err)
}
if base == diffQuote {
t.Fatalf("expected different fingerprint for different quotation_ref")
}
diffClient, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-1",
ClientPaymentRef: "client-2",
})
if err != nil {
t.Fatalf("Fingerprint returned error: %v", err)
}
if base == diffClient {
t.Fatalf("expected different fingerprint for different client_payment_ref")
}
}
func TestFingerprint_RequiresBusinessFields(t *testing.T) {
svc := New()
if _, err := svc.Fingerprint(FPInput{
QuotationRef: "quote-1",
}); err == nil {
t.Fatal("expected error for empty organization_ref")
}
if _, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
}); err == nil {
t.Fatal("expected error for empty quotation_ref")
}
}
func TestTryReuse_NotFound(t *testing.T) {
svc := New()
store := &fakeStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
return nil, storage.ErrPaymentNotFound
},
}
payment, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
})
if err != nil {
t.Fatalf("TryReuse returned error: %v", err)
}
if reused {
t.Fatal("expected reused=false")
}
if payment != nil {
t.Fatal("expected nil payment")
}
}
func TestTryReuse_ParamMismatch(t *testing.T) {
svc := New()
store := &fakeStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
return &model.Payment{
Metadata: map[string]string{
reqHashMetaKey: "stored-hash",
},
}, nil
},
}
_, _, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "new-hash",
})
if !errors.Is(err, ErrIdempotencyParamMismatch) {
t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err)
}
}
func TestTryReuse_Success(t *testing.T) {
svc := New()
existing := &model.Payment{
PaymentRef: "pay-1",
Metadata: map[string]string{
reqHashMetaKey: "hash-1",
},
}
store := &fakeStore{
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
return existing, nil
},
}
payment, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
})
if err != nil {
t.Fatalf("TryReuse returned error: %v", err)
}
if !reused {
t.Fatal("expected reused=true")
}
if payment != existing {
t.Fatalf("expected existing payment, got %+v", payment)
}
}
func TestCreateOrReuse_CreateSuccess(t *testing.T) {
svc := New()
store := &fakeStore{
createFn: func(context.Context, *model.Payment) error { return nil },
}
newPayment := &model.Payment{
PaymentRef: "pay-new",
}
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Payment: newPayment,
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
},
})
if err != nil {
t.Fatalf("CreateOrReuse returned error: %v", err)
}
if reused {
t.Fatal("expected reused=false")
}
if got != newPayment {
t.Fatalf("expected created payment, got %+v", got)
}
if got.Metadata == nil || got.Metadata[reqHashMetaKey] != "hash-1" {
t.Fatalf("expected payment metadata hash, got %+v", got.Metadata)
}
}
func TestCreateOrReuse_DuplicateReturnsExisting(t *testing.T) {
svc := New()
existing := &model.Payment{
PaymentRef: "pay-existing",
Metadata: map[string]string{
reqHashMetaKey: "hash-1",
},
}
store := &fakeStore{
createFn: func(context.Context, *model.Payment) error { return storage.ErrDuplicatePayment },
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
return existing, nil
},
}
newPayment := &model.Payment{PaymentRef: "pay-new"}
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Payment: newPayment,
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
},
})
if err != nil {
t.Fatalf("CreateOrReuse returned error: %v", err)
}
if !reused {
t.Fatal("expected reused=true")
}
if got != existing {
t.Fatalf("expected existing payment, got %+v", got)
}
}
func TestCreateOrReuse_DuplicateParamMismatch(t *testing.T) {
svc := New()
store := &fakeStore{
createFn: func(context.Context, *model.Payment) error { return storage.ErrDuplicatePayment },
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
return &model.Payment{
Metadata: map[string]string{
reqHashMetaKey: "stored-hash",
},
}, nil
},
}
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Payment: &model.Payment{PaymentRef: "pay-new"},
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "new-hash",
},
})
if !errors.Is(err, ErrIdempotencyParamMismatch) {
t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err)
}
}
func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing.T) {
svc := New()
store := &fakeStore{
createFn: func(context.Context, *model.Payment) error { return storage.ErrDuplicatePayment },
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.Payment, error) {
return nil, storage.ErrPaymentNotFound
},
}
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
Payment: &model.Payment{PaymentRef: "pay-new"},
Reuse: ReuseInput{
OrganizationID: bson.NewObjectID(),
IdempotencyKey: "idem-1",
Fingerprint: "hash-1",
},
})
if !errors.Is(err, storage.ErrDuplicatePayment) {
t.Fatalf("expected ErrDuplicatePayment, got %v", err)
}
}
type fakeStore struct {
createFn func(ctx context.Context, payment *model.Payment) error
getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
}
func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error {
if f.createFn == nil {
return nil
}
return f.createFn(ctx, payment)
}
func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) {
if f.getByIdempotencyKeyFn == nil {
return nil, storage.ErrPaymentNotFound
}
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
}

View File

@@ -0,0 +1,10 @@
package qsnap
import "errors"
var (
ErrQuoteNotFound = errors.New("quotation_ref not found")
ErrQuoteExpired = errors.New("quotation_ref expired")
ErrQuoteNotExecutable = errors.New("quotation_ref is not executable")
ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch")
)

View File

@@ -0,0 +1,38 @@
package qsnap
import (
"context"
"time"
"github.com/tech/sendico/payments/storage/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Store is the minimal quote store contract required by the resolver.
type Store interface {
GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
}
// Resolver resolves a quotation reference into canonical execution snapshots.
type Resolver interface {
Resolve(ctx context.Context, store Store, in Input) (*Output, error)
}
// Input defines lookup scope for quotation resolution.
type Input struct {
OrganizationID bson.ObjectID
QuotationRef string
}
// Output contains extracted canonical snapshots for execution.
type Output struct {
QuotationRef string
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
}
func New() Resolver {
return &svc{
now: time.Now,
}
}

View File

@@ -0,0 +1,202 @@
package qsnap
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type svc struct {
now func() time.Time
}
func (s *svc) Resolve(
ctx context.Context,
store Store,
in Input,
) (*Output, error) {
if store == nil {
return nil, merrors.InvalidArgument("quotes store is required")
}
if in.OrganizationID.IsZero() {
return nil, merrors.InvalidArgument("organization_id is required")
}
quoteRef := strings.TrimSpace(in.QuotationRef)
if quoteRef == "" {
return nil, merrors.InvalidArgument("quotation_ref is required")
}
record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef)
if err != nil {
if errors.Is(err, quotestorage.ErrQuoteNotFound) || errors.Is(err, merrors.ErrNoData) {
return nil, ErrQuoteNotFound
}
return nil, err
}
if record == nil {
return nil, ErrQuoteNotFound
}
if err := ensureExecutable(record, s.now().UTC()); err != nil {
return nil, err
}
intentSnapshot, err := extractIntentSnapshot(record)
if err != nil {
return nil, err
}
quoteSnapshot, err := extractQuoteSnapshot(record)
if err != nil {
return nil, err
}
outputRef := strings.TrimSpace(record.QuoteRef)
if outputRef == "" {
outputRef = quoteRef
}
if quoteSnapshot != nil && strings.TrimSpace(quoteSnapshot.QuoteRef) == "" {
quoteSnapshot.QuoteRef = outputRef
}
return &Output{
QuotationRef: outputRef,
IntentSnapshot: intentSnapshot,
QuoteSnapshot: quoteSnapshot,
}, nil
}
func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error {
if record == nil {
return ErrQuoteNotFound
}
if !record.ExpiresAt.IsZero() && now.After(record.ExpiresAt.UTC()) {
return ErrQuoteExpired
}
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
return fmt.Errorf("%w: %s", ErrQuoteNotExecutable, note)
}
status, err := extractSingleStatus(record)
if err != nil {
return err
}
if status == nil {
// Legacy records may not have status metadata.
return nil
}
switch status.State {
case model.QuoteStateExecutable:
return nil
case model.QuoteStateExpired:
return ErrQuoteExpired
case model.QuoteStateBlocked:
reason := strings.TrimSpace(string(status.BlockReason))
if reason != "" && reason != string(model.QuoteBlockReasonUnspecified) {
return fmt.Errorf("%w: blocked (%s)", ErrQuoteNotExecutable, reason)
}
return fmt.Errorf("%w: blocked", ErrQuoteNotExecutable)
case model.QuoteStateIndicative:
return fmt.Errorf("%w: indicative", ErrQuoteNotExecutable)
default:
state := strings.TrimSpace(string(status.State))
if state == "" {
return fmt.Errorf("%w: unspecified status", ErrQuoteNotExecutable)
}
return fmt.Errorf("%w: state=%s", ErrQuoteNotExecutable, state)
}
}
func extractSingleStatus(record *model.PaymentQuoteRecord) (*model.QuoteStatusV2, error) {
if record == nil {
return nil, ErrQuoteShapeMismatch
}
if len(record.StatusesV2) > 0 {
if len(record.StatusesV2) != 1 {
return nil, fmt.Errorf("%w: expected single status", ErrQuoteShapeMismatch)
}
if record.StatusesV2[0] == nil {
return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch)
}
return record.StatusesV2[0], nil
}
return record.StatusV2, nil
}
func extractIntentSnapshot(record *model.PaymentQuoteRecord) (model.PaymentIntent, error) {
if record == nil {
return model.PaymentIntent{}, ErrQuoteShapeMismatch
}
switch {
case len(record.Intents) > 1:
return model.PaymentIntent{}, fmt.Errorf("%w: expected single intent", ErrQuoteShapeMismatch)
case len(record.Intents) == 1:
return cloneIntentSnapshot(record.Intents[0])
}
if isEmptyIntentSnapshot(record.Intent) {
return model.PaymentIntent{}, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch)
}
return cloneIntentSnapshot(record.Intent)
}
func extractQuoteSnapshot(record *model.PaymentQuoteRecord) (*model.PaymentQuoteSnapshot, error) {
if record == nil {
return nil, ErrQuoteShapeMismatch
}
if record.Quote != nil {
return cloneQuoteSnapshot(record.Quote)
}
if len(record.Quotes) > 1 {
return nil, fmt.Errorf("%w: expected single quote", ErrQuoteShapeMismatch)
}
if len(record.Quotes) == 1 {
if record.Quotes[0] == nil {
return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch)
}
return cloneQuoteSnapshot(record.Quotes[0])
}
return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch)
}
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
var dst model.PaymentIntent
if err := bsonClone(src, &dst); err != nil {
return model.PaymentIntent{}, err
}
return dst, nil
}
func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSnapshot, error) {
if src == nil {
return nil, nil
}
dst := &model.PaymentQuoteSnapshot{}
if err := bsonClone(src, dst); err != nil {
return nil, err
}
return dst, nil
}
func bsonClone(src any, dst any) error {
data, err := bson.Marshal(src)
if err != nil {
return err
}
return bson.Unmarshal(data, dst)
}
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
}

View File

@@ -0,0 +1,303 @@
package qsnap
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestResolve_SingleShapeOK(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
orgID := bson.NewObjectID()
record := &model.PaymentQuoteRecord{
QuoteRef: "stored-quote-ref",
Intent: model.PaymentIntent{
Ref: "intent-1",
Kind: model.PaymentKindPayout,
},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: "",
},
StatusV2: &model.QuoteStatusV2{
State: model.QuoteStateExecutable,
},
ExpiresAt: now.Add(time.Minute),
}
resolver := &svc{
now: func() time.Time { return now },
}
out, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return record, nil
},
}, Input{
OrganizationID: orgID,
QuotationRef: "stored-quote-ref",
})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if got, want := out.QuotationRef, "stored-quote-ref"; got != want {
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
}
if out.QuoteSnapshot == nil {
t.Fatal("expected quote snapshot")
}
if got, want := out.QuoteSnapshot.QuoteRef, "stored-quote-ref"; got != want {
t.Fatalf("quote_snapshot.quote_ref mismatch: got=%q want=%q", got, want)
}
out.QuoteSnapshot.QuoteRef = "changed"
if record.Quote.QuoteRef != "" {
t.Fatalf("expected stored quote snapshot to be unchanged, got %q", record.Quote.QuoteRef)
}
}
func TestResolve_ArrayShapeOK(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
orgID := bson.NewObjectID()
record := &model.PaymentQuoteRecord{
QuoteRef: "batch-like-single",
Intents: []model.PaymentIntent{
{Ref: "intent-1", Kind: model.PaymentKindInternalTransfer},
},
Quotes: []*model.PaymentQuoteSnapshot{
{QuoteRef: "snapshot-ref"},
},
StatusesV2: []*model.QuoteStatusV2{
{State: model.QuoteStateExecutable},
},
ExpiresAt: now.Add(time.Minute),
}
resolver := &svc{
now: func() time.Time { return now },
}
out, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return record, nil
},
}, Input{
OrganizationID: orgID,
QuotationRef: "batch-like-single",
})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
}
if out.QuoteSnapshot == nil {
t.Fatal("expected quote snapshot")
}
if got, want := out.QuoteSnapshot.QuoteRef, "snapshot-ref"; got != want {
t.Fatalf("quote_snapshot.quote_ref mismatch: got=%q want=%q", got, want)
}
}
func TestResolve_NotFound(t *testing.T) {
resolver := New()
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return nil, quotestorage.ErrQuoteNotFound
},
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
})
if !errors.Is(err, ErrQuoteNotFound) {
t.Fatalf("expected ErrQuoteNotFound, got %v", err)
}
}
func TestResolve_Expired(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
},
Quote: &model.PaymentQuoteSnapshot{},
StatusV2: &model.QuoteStatusV2{
State: model.QuoteStateExecutable,
},
ExpiresAt: now.Add(-time.Second),
}, nil
},
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
})
if !errors.Is(err, ErrQuoteExpired) {
t.Fatalf("expected ErrQuoteExpired, got %v", err)
}
}
func TestResolve_NotExecutableState(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
},
Quote: &model.PaymentQuoteSnapshot{},
StatusV2: &model.QuoteStatusV2{
State: model.QuoteStateBlocked,
BlockReason: model.QuoteBlockReasonRouteUnavailable,
},
ExpiresAt: now.Add(time.Minute),
}, nil
},
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
})
if !errors.Is(err, ErrQuoteNotExecutable) {
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
}
}
func TestResolve_NotExecutableExecutionNote(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
},
Quote: &model.PaymentQuoteSnapshot{},
ExecutionNote: "quote will not be executed",
ExpiresAt: now.Add(time.Minute),
}, nil
},
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
})
if !errors.Is(err, ErrQuoteNotExecutable) {
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
}
}
func TestResolve_ShapeMismatch(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intents: []model.PaymentIntent{
{Kind: model.PaymentKindPayout},
{Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{},
{},
},
ExpiresAt: now.Add(time.Minute),
}, nil
},
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
})
if !errors.Is(err, ErrQuoteShapeMismatch) {
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)
}
}
func TestResolve_InputValidation(t *testing.T) {
resolver := New()
orgID := bson.NewObjectID()
tests := []struct {
name string
store Store
in Input
}{
{
name: "nil store",
store: nil,
in: Input{
OrganizationID: orgID,
QuotationRef: "quote-ref",
},
},
{
name: "empty org id",
store: &fakeStore{},
in: Input{
QuotationRef: "quote-ref",
},
},
{
name: "empty quotation ref",
store: &fakeStore{},
in: Input{
OrganizationID: orgID,
QuotationRef: " ",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
_, err := resolver.Resolve(context.Background(), tt.store, tt.in)
if err == nil {
t.Fatal("expected error")
}
})
}
}
type fakeStore struct {
getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
}
func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
if f.getByRefFn == nil {
return nil, quotestorage.ErrQuoteNotFound
}
return f.getByRefFn(ctx, orgRef, quoteRef)
}

View File

@@ -0,0 +1,40 @@
package reqval
import "go.mongodb.org/mongo-driver/v2/bson"
// Validator validates execute-payment inputs and returns a normalized context.
type Validator interface {
Validate(req *Req) (*Ctx, error)
}
// Req is the execute-payment request shape used by the validator module.
// It is intentionally transport-agnostic, so it can be mapped from proto later.
type Req struct {
Meta *Meta
QuotationRef string
ClientPaymentRef string
}
// Meta carries organization and trace context fields required for validation.
type Meta struct {
OrganizationRef string
Trace *Trace
}
// Trace carries trace-bound idempotency key for execution requests.
type Trace struct {
IdempotencyKey string
}
// Ctx is the normalized output used by downstream execute-payment flow.
type Ctx struct {
OrganizationRef string
OrganizationID bson.ObjectID
IdempotencyKey string
QuotationRef string
ClientPaymentRef string
}
func New() Validator {
return &svc{}
}

View File

@@ -0,0 +1,81 @@
package reqval
import (
"regexp"
"strings"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
maxIdempotencyKeyLen = 256
maxQuotationRefLen = 128
maxClientRefLen = 128
)
var refTokenRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*$`)
type svc struct{}
func (s *svc) Validate(req *Req) (*Ctx, error) {
if req == nil {
return nil, merrors.InvalidArgument("request is required")
}
if req.Meta == nil {
return nil, merrors.InvalidArgument("meta is required")
}
orgRef := strings.TrimSpace(req.Meta.OrganizationRef)
if orgRef == "" {
return nil, merrors.InvalidArgument("meta.organization_ref is required")
}
orgID, err := bson.ObjectIDFromHex(orgRef)
if err != nil {
return nil, merrors.InvalidArgument("meta.organization_ref must be a valid objectID")
}
if req.Meta.Trace == nil {
return nil, merrors.InvalidArgument("meta.trace is required")
}
idempotencyKey, err := validateRefToken("meta.trace.idempotency_key", req.Meta.Trace.IdempotencyKey, maxIdempotencyKeyLen, true)
if err != nil {
return nil, err
}
quotationRef, err := validateRefToken("quotation_ref", req.QuotationRef, maxQuotationRefLen, true)
if err != nil {
return nil, err
}
clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false)
if err != nil {
return nil, err
}
return &Ctx{
OrganizationRef: orgRef,
OrganizationID: orgID,
IdempotencyKey: idempotencyKey,
QuotationRef: quotationRef,
ClientPaymentRef: clientPaymentRef,
}, nil
}
func validateRefToken(field, value string, maxLen int, required bool) (string, error) {
normalized := strings.TrimSpace(value)
if normalized == "" {
if required {
return "", merrors.InvalidArgument(field + " is required")
}
return "", nil
}
if maxLen > 0 && len(normalized) > maxLen {
return "", merrors.InvalidArgument(field + " is too long")
}
if !refTokenRe.MatchString(normalized) {
return "", merrors.InvalidArgument(field + " has invalid format")
}
return normalized, nil
}

View File

@@ -0,0 +1,211 @@
package reqval
import (
"strings"
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestValidate_OK(t *testing.T) {
v := New()
orgID := bson.NewObjectID()
ctx, err := v.Validate(&Req{
Meta: &Meta{
OrganizationRef: " " + orgID.Hex() + " ",
Trace: &Trace{
IdempotencyKey: " idem-1:alpha ",
},
},
QuotationRef: " quote-ref-1 ",
ClientPaymentRef: " client.ref-1 ",
})
if err != nil {
t.Fatalf("Validate returned error: %v", err)
}
if ctx == nil {
t.Fatal("expected ctx")
}
if got, want := ctx.OrganizationRef, orgID.Hex(); got != want {
t.Fatalf("organization_ref mismatch: got=%q want=%q", got, want)
}
if got, want := ctx.OrganizationID, orgID; got != want {
t.Fatalf("organization_id mismatch: got=%s want=%s", got.Hex(), want.Hex())
}
if got, want := ctx.IdempotencyKey, "idem-1:alpha"; got != want {
t.Fatalf("idempotency_key mismatch: got=%q want=%q", got, want)
}
if got, want := ctx.QuotationRef, "quote-ref-1"; got != want {
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := ctx.ClientPaymentRef, "client.ref-1"; got != want {
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
}
}
func TestValidate_ClientPaymentRefOptional(t *testing.T) {
v := New()
orgID := bson.NewObjectID()
ctx, err := v.Validate(&Req{
Meta: &Meta{
OrganizationRef: orgID.Hex(),
Trace: &Trace{
IdempotencyKey: "idem-1",
},
},
QuotationRef: "quote-1",
})
if err != nil {
t.Fatalf("Validate returned error: %v", err)
}
if ctx == nil {
t.Fatal("expected ctx")
}
if ctx.ClientPaymentRef != "" {
t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef)
}
}
func TestValidate_Errors(t *testing.T) {
orgID := bson.NewObjectID().Hex()
tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen)
tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen)
tooLongClient := "c" + strings.Repeat("a", maxClientRefLen)
tests := []struct {
name string
req *Req
}{
{
name: "nil request",
req: nil,
},
{
name: "nil meta",
req: &Req{},
},
{
name: "empty org ref",
req: &Req{
Meta: &Meta{
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: "quote-1",
},
},
{
name: "invalid org ref",
req: &Req{
Meta: &Meta{
OrganizationRef: "not-an-object-id",
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: "quote-1",
},
},
{
name: "nil trace",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
},
QuotationRef: "quote-1",
},
},
{
name: "empty idempotency key",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: " "},
},
QuotationRef: "quote-1",
},
},
{
name: "too long idempotency key",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: tooLongIdem},
},
QuotationRef: "quote-1",
},
},
{
name: "bad idempotency key shape",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem key"},
},
QuotationRef: "quote-1",
},
},
{
name: "empty quotation ref",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: " ",
},
},
{
name: "too long quotation ref",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: tooLongQuote,
},
},
{
name: "bad quotation ref shape",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: "quote ref",
},
},
{
name: "too long client payment ref",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: "quote-1",
ClientPaymentRef: tooLongClient,
},
},
{
name: "bad client payment ref shape",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: "quote-1",
ClientPaymentRef: "client payment ref",
},
},
}
v := New()
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
ctx, err := v.Validate(tt.req)
if err == nil {
t.Fatalf("expected error, got ctx=%+v", ctx)
}
})
}
}

View File

@@ -15,3 +15,47 @@ func factory(logger mlogger.Logger, file string, debug bool) (server.Application
func main() { func main() {
smain.RunServer("main", appversion.Create(), factory) smain.RunServer("main", appversion.Create(), factory)
} }
/*
execute_payment_request_validator
Validate meta.organization_ref, meta.trace.idempotency_key, quotation_ref, client_payment_ref shape (idempotency key is in trace: trace.proto (line 10)).
payment_idempotency_service
Fingerprint business payload (org + quotation_ref + client_payment_ref), dedupe create/retry behavior, and mismatch detection (same pattern as quote idempotency).
quote_snapshot_resolver
Resolve quotation_ref, enforce executable/non-expired quote, extract canonical intent_snapshot + quote_snapshot.
payment_aggregate_factory_v2
Create initial aggregate: state=CREATED, version=1, immutable snapshots, and initial step telemetry shell.
execution_plan_compiler_v2
Compile runtime step graph from quote route + execution conditions + intent.
Important: do not reuse old route/template lookup path as primary (plan_builder route/template selection) because v2 quote already selected route.
orchestration_state_machine
Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...).
step_scheduler_runtime
Pick runnable steps, manage dependency checks, retries/attempts, and mark blocked/skipped.
step_executor_registry
Rail/action executors (ledger, crypto, provider_settlement, card_payout, observe_confirm) behind interfaces.
external_event_reconciler
Consume async gateway/ledger/card events, map to step updates, append external refs, advance aggregate state.
payment_repository_v2
Persistence with optimistic concurrency (version CAS), plus indexes for:
(org,payment_ref), (org,idempotency_key), (org,quotation_ref,created_at), (org,state,created_at).
payment_query_service_v2
GetPayment and ListPayments with v2 filters (states, quotation_ref, created_from/to, cursor) and efficient projections.
payment_response_mapper_v2
Map internal aggregate to proto Payment and enforce response invariants (same role as quote mapper/invariants).
orchestration_observability
Metrics, structured logs, audit timeline per payment and per step attempt.
*/

View File

@@ -36,11 +36,34 @@ func (crn *ConfirmationResultNotification) Serialize() ([]byte, error) {
return crn.Envelope.Wrap(data) return crn.Envelope.Wrap(data)
} }
type ConfirmationDispatchNotification struct {
messaging.Envelope
payload model.ConfirmationRequestDispatch
}
func (cdn *ConfirmationDispatchNotification) Serialize() ([]byte, error) {
data, err := json.Marshal(cdn.payload)
if err != nil {
return nil, err
}
return cdn.Envelope.Wrap(data)
}
func confirmationRequestEvent() model.NotificationEvent { func confirmationRequestEvent() model.NotificationEvent {
return model.NewNotification(mservice.Notifications, nm.NAConfirmationRequest) return model.NewNotification(mservice.Notifications, nm.NAConfirmationRequest)
} }
func confirmationResultEvent(sourceService, rail string) model.NotificationEvent { func confirmationResultEvent(sourceService, rail string) model.NotificationEvent {
sourceService, rail = normalizeSourceRail(sourceService, rail)
return model.NewNotification(mservice.Verification, nm.NotificationAction(sourceService+"."+rail))
}
func confirmationDispatchEvent(sourceService, rail string) model.NotificationEvent {
sourceService, rail = normalizeSourceRail(sourceService, rail)
return model.NewNotification(mservice.Verification, nm.NotificationAction(sourceService+"."+rail+".dispatch"))
}
func normalizeSourceRail(sourceService, rail string) (string, string) {
action := strings.TrimSpace(sourceService) action := strings.TrimSpace(sourceService)
if action == "" { if action == "" {
action = "unknown" action = "unknown"
@@ -51,7 +74,7 @@ func confirmationResultEvent(sourceService, rail string) model.NotificationEvent
rail = "default" rail = "default"
} }
rail = strings.ToLower(rail) rail = strings.ToLower(rail)
return model.NewNotification(mservice.Verification, nm.NotificationAction(action+"."+rail)) return action, rail
} }
func NewConfirmationRequestEnvelope(sender string, request *model.ConfirmationRequest) messaging.Envelope { func NewConfirmationRequestEnvelope(sender string, request *model.ConfirmationRequest) messaging.Envelope {
@@ -75,3 +98,14 @@ func NewConfirmationResultEnvelope(sender string, result *model.ConfirmationResu
payload: payload, payload: payload,
} }
} }
func NewConfirmationDispatchEnvelope(sender string, dispatch *model.ConfirmationRequestDispatch, sourceService, rail string) messaging.Envelope {
var payload model.ConfirmationRequestDispatch
if dispatch != nil {
payload = *dispatch
}
return &ConfirmationDispatchNotification{
Envelope: messaging.CreateEnvelope(sender, confirmationDispatchEvent(sourceService, rail)),
payload: payload,
}
}

View File

@@ -58,6 +58,29 @@ func (crp *ConfirmationResultProcessor) GetSubject() model.NotificationEvent {
return crp.event return crp.event
} }
type ConfirmationDispatchProcessor struct {
logger mlogger.Logger
handler ch.ConfirmationDispatchHandler
event model.NotificationEvent
}
func (cdp *ConfirmationDispatchProcessor) Process(ctx context.Context, envelope me.Envelope) error {
var msg model.ConfirmationRequestDispatch
if err := json.Unmarshal(envelope.GetData(), &msg); err != nil {
cdp.logger.Warn("Failed to decode confirmation dispatch envelope", zap.Error(err), zap.String("topic", cdp.event.ToString()))
return err
}
if cdp.handler == nil {
cdp.logger.Warn("Confirmation dispatch handler is not configured", zap.String("topic", cdp.event.ToString()))
return nil
}
return cdp.handler(ctx, &msg)
}
func (cdp *ConfirmationDispatchProcessor) GetSubject() model.NotificationEvent {
return cdp.event
}
func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor { func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor {
if logger != nil { if logger != nil {
logger = logger.Named("confirmation_request_processor") logger = logger.Named("confirmation_request_processor")
@@ -79,3 +102,14 @@ func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail s
event: confirmationResultEvent(sourceService, rail), event: confirmationResultEvent(sourceService, rail),
} }
} }
func NewConfirmationDispatchProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationDispatchHandler) np.EnvelopeProcessor {
if logger != nil {
logger = logger.Named("confirmation_dispatch_processor")
}
return &ConfirmationDispatchProcessor{
logger: logger,
handler: handler,
event: confirmationDispatchEvent(sourceService, rail),
}
}

View File

@@ -26,6 +26,40 @@ func telegramReactionEvent() model.NotificationEvent {
return model.NewNotification(mservice.Notifications, nm.NATelegramReaction) return model.NewNotification(mservice.Notifications, nm.NATelegramReaction)
} }
type TelegramTextNotification struct {
messaging.Envelope
payload model.TelegramTextRequest
}
func (ttn *TelegramTextNotification) Serialize() ([]byte, error) {
data, err := json.Marshal(ttn.payload)
if err != nil {
return nil, err
}
return ttn.Envelope.Wrap(data)
}
func telegramTextEvent() model.NotificationEvent {
return model.NewNotification(mservice.Notifications, nm.NATelegramText)
}
type TelegramUpdateNotification struct {
messaging.Envelope
payload model.TelegramWebhookUpdate
}
func (tun *TelegramUpdateNotification) Serialize() ([]byte, error) {
data, err := json.Marshal(tun.payload)
if err != nil {
return nil, err
}
return tun.Envelope.Wrap(data)
}
func telegramUpdateEvent() model.NotificationEvent {
return model.NewNotification(mservice.Notifications, nm.NATelegramUpdate)
}
func NewTelegramReactionEnvelope(sender string, request *model.TelegramReactionRequest) messaging.Envelope { func NewTelegramReactionEnvelope(sender string, request *model.TelegramReactionRequest) messaging.Envelope {
var payload model.TelegramReactionRequest var payload model.TelegramReactionRequest
if request != nil { if request != nil {
@@ -36,3 +70,25 @@ func NewTelegramReactionEnvelope(sender string, request *model.TelegramReactionR
payload: payload, payload: payload,
} }
} }
func NewTelegramTextEnvelope(sender string, request *model.TelegramTextRequest) messaging.Envelope {
var payload model.TelegramTextRequest
if request != nil {
payload = *request
}
return &TelegramTextNotification{
Envelope: messaging.CreateEnvelope(sender, telegramTextEvent()),
payload: payload,
}
}
func NewTelegramUpdateEnvelope(sender string, update *model.TelegramWebhookUpdate) messaging.Envelope {
var payload model.TelegramWebhookUpdate
if update != nil {
payload = *update
}
return &TelegramUpdateNotification{
Envelope: messaging.CreateEnvelope(sender, telegramUpdateEvent()),
payload: payload,
}
}

View File

@@ -45,3 +45,71 @@ func NewTelegramReactionProcessor(logger mlogger.Logger, handler ch.TelegramReac
event: telegramReactionEvent(), event: telegramReactionEvent(),
} }
} }
type TelegramTextProcessor struct {
logger mlogger.Logger
handler ch.TelegramTextHandler
event model.NotificationEvent
}
func (ttp *TelegramTextProcessor) Process(ctx context.Context, envelope me.Envelope) error {
var msg model.TelegramTextRequest
if err := json.Unmarshal(envelope.GetData(), &msg); err != nil {
ttp.logger.Warn("Failed to decode telegram text envelope", zap.Error(err), zap.String("topic", ttp.event.ToString()))
return err
}
if ttp.handler == nil {
ttp.logger.Warn("Telegram text handler is not configured", zap.String("topic", ttp.event.ToString()))
return nil
}
return ttp.handler(ctx, &msg)
}
func (ttp *TelegramTextProcessor) GetSubject() model.NotificationEvent {
return ttp.event
}
func NewTelegramTextProcessor(logger mlogger.Logger, handler ch.TelegramTextHandler) np.EnvelopeProcessor {
if logger != nil {
logger = logger.Named("telegram_text_processor")
}
return &TelegramTextProcessor{
logger: logger,
handler: handler,
event: telegramTextEvent(),
}
}
type TelegramUpdateProcessor struct {
logger mlogger.Logger
handler ch.TelegramUpdateHandler
event model.NotificationEvent
}
func (tup *TelegramUpdateProcessor) Process(ctx context.Context, envelope me.Envelope) error {
var msg model.TelegramWebhookUpdate
if err := json.Unmarshal(envelope.GetData(), &msg); err != nil {
tup.logger.Warn("Failed to decode telegram webhook update envelope", zap.Error(err), zap.String("topic", tup.event.ToString()))
return err
}
if tup.handler == nil {
tup.logger.Warn("Telegram update handler is not configured", zap.String("topic", tup.event.ToString()))
return nil
}
return tup.handler(ctx, &msg)
}
func (tup *TelegramUpdateProcessor) GetSubject() model.NotificationEvent {
return tup.event
}
func NewTelegramUpdateProcessor(logger mlogger.Logger, handler ch.TelegramUpdateHandler) np.EnvelopeProcessor {
if logger != nil {
logger = logger.Named("telegram_update_processor")
}
return &TelegramUpdateProcessor{
logger: logger,
handler: handler,
event: telegramUpdateEvent(),
}
}

View File

@@ -17,6 +17,10 @@ func ConfirmationResult(sender string, result *model.ConfirmationResult, sourceS
return cinternal.NewConfirmationResultEnvelope(sender, result, sourceService, rail) return cinternal.NewConfirmationResultEnvelope(sender, result, sourceService, rail)
} }
func ConfirmationDispatch(sender string, dispatch *model.ConfirmationRequestDispatch, sourceService, rail string) messaging.Envelope {
return cinternal.NewConfirmationDispatchEnvelope(sender, dispatch, sourceService, rail)
}
func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor { func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor {
return cinternal.NewConfirmationRequestProcessor(logger, handler) return cinternal.NewConfirmationRequestProcessor(logger, handler)
} }
@@ -24,3 +28,7 @@ func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.Confirmat
func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor { func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor {
return cinternal.NewConfirmationResultProcessor(logger, sourceService, rail, handler) return cinternal.NewConfirmationResultProcessor(logger, sourceService, rail, handler)
} }
func NewConfirmationDispatchProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationDispatchHandler) np.EnvelopeProcessor {
return cinternal.NewConfirmationDispatchProcessor(logger, sourceService, rail, handler)
}

View File

@@ -9,3 +9,5 @@ import (
type ConfirmationRequestHandler = func(context.Context, *model.ConfirmationRequest) error type ConfirmationRequestHandler = func(context.Context, *model.ConfirmationRequest) error
type ConfirmationResultHandler = func(context.Context, *model.ConfirmationResult) error type ConfirmationResultHandler = func(context.Context, *model.ConfirmationResult) error
type ConfirmationDispatchHandler = func(context.Context, *model.ConfirmationRequestDispatch) error

View File

@@ -7,3 +7,7 @@ import (
) )
type TelegramReactionHandler = func(context.Context, *model.TelegramReactionRequest) error type TelegramReactionHandler = func(context.Context, *model.TelegramReactionRequest) error
type TelegramTextHandler = func(context.Context, *model.TelegramTextRequest) error
type TelegramUpdateHandler = func(context.Context, *model.TelegramWebhookUpdate) error

View File

@@ -13,6 +13,22 @@ func TelegramReaction(sender string, request *model.TelegramReactionRequest) mes
return tinternal.NewTelegramReactionEnvelope(sender, request) return tinternal.NewTelegramReactionEnvelope(sender, request)
} }
func TelegramText(sender string, request *model.TelegramTextRequest) messaging.Envelope {
return tinternal.NewTelegramTextEnvelope(sender, request)
}
func TelegramUpdate(sender string, update *model.TelegramWebhookUpdate) messaging.Envelope {
return tinternal.NewTelegramUpdateEnvelope(sender, update)
}
func NewTelegramReactionProcessor(logger mlogger.Logger, handler ch.TelegramReactionHandler) np.EnvelopeProcessor { func NewTelegramReactionProcessor(logger mlogger.Logger, handler ch.TelegramReactionHandler) np.EnvelopeProcessor {
return tinternal.NewTelegramReactionProcessor(logger, handler) return tinternal.NewTelegramReactionProcessor(logger, handler)
} }
func NewTelegramTextProcessor(logger mlogger.Logger, handler ch.TelegramTextHandler) np.EnvelopeProcessor {
return tinternal.NewTelegramTextProcessor(logger, handler)
}
func NewTelegramUpdateProcessor(logger mlogger.Logger, handler ch.TelegramUpdateHandler) np.EnvelopeProcessor {
return tinternal.NewTelegramUpdateProcessor(logger, handler)
}

View File

@@ -33,3 +33,13 @@ type ConfirmationResult struct {
Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"` Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"`
ParseError string `bson:"parseError,omitempty" json:"parse_error,omitempty"` ParseError string `bson:"parseError,omitempty" json:"parse_error,omitempty"`
} }
// ConfirmationRequestDispatch is emitted by the notification service after it sends
// a confirmation prompt message to Telegram.
type ConfirmationRequestDispatch struct {
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"`
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"`
}

View File

@@ -63,7 +63,7 @@ func FromStringImp(s string) (*NotificationEventImp, error) {
func StringToNotificationAction(s string) (nm.NotificationAction, error) { func StringToNotificationAction(s string) (nm.NotificationAction, error) {
switch nm.NotificationAction(s) { switch nm.NotificationAction(s) {
case nm.NACreated, nm.NAPending, nm.NAUpdated, nm.NAArchived, nm.NADeleted, nm.NAAssigned, nm.NAPasswordReset, nm.NAConfirmationRequest, nm.NATelegramReaction, nm.NAPaymentGatewayIntent, nm.NAPaymentGatewayExecution, nm.NADiscoveryServiceAnnounce, nm.NADiscoveryGatewayAnnounce, nm.NADiscoveryHeartbeat, nm.NADiscoveryLookupRequest, nm.NADiscoveryLookupResponse, nm.NADiscoveryRefreshUI: case nm.NACreated, nm.NAPending, nm.NAUpdated, nm.NAArchived, nm.NADeleted, nm.NAAssigned, nm.NAPasswordReset, nm.NAConfirmationRequest, nm.NATelegramReaction, nm.NATelegramText, nm.NATelegramUpdate, nm.NAPaymentGatewayIntent, nm.NAPaymentGatewayExecution, nm.NADiscoveryServiceAnnounce, nm.NADiscoveryGatewayAnnounce, nm.NADiscoveryHeartbeat, nm.NADiscoveryLookupRequest, nm.NADiscoveryLookupResponse, nm.NADiscoveryRefreshUI:
return nm.NotificationAction(s), nil return nm.NotificationAction(s), nil
default: default:
return "", merrors.DataConflict("invalid Notification action: " + s) return "", merrors.DataConflict("invalid Notification action: " + s)

View File

@@ -14,6 +14,8 @@ const (
NAConfirmationRequest NotificationAction = "confirmation.request" NAConfirmationRequest NotificationAction = "confirmation.request"
NATelegramReaction NotificationAction = "telegram.reaction" NATelegramReaction NotificationAction = "telegram.reaction"
NATelegramText NotificationAction = "telegram.text"
NATelegramUpdate NotificationAction = "telegram.update"
NAPaymentGatewayIntent NotificationAction = "intent.request" NAPaymentGatewayIntent NotificationAction = "intent.request"
NAPaymentGatewayExecution NotificationAction = "execution.result" NAPaymentGatewayExecution NotificationAction = "execution.result"

View File

@@ -85,6 +85,8 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) {
nm.NAPasswordReset, nm.NAPasswordReset,
nm.NAConfirmationRequest, nm.NAConfirmationRequest,
nm.NATelegramReaction, nm.NATelegramReaction,
nm.NATelegramText,
nm.NATelegramUpdate,
nm.NAPaymentGatewayIntent, nm.NAPaymentGatewayIntent,
nm.NAPaymentGatewayExecution, nm.NAPaymentGatewayExecution,
nm.NADiscoveryServiceAnnounce, nm.NADiscoveryServiceAnnounce,

View File

@@ -16,3 +16,15 @@ type TelegramReactionRequest struct {
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"` MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
Emoji string `bson:"emoji,omitempty" json:"emoji,omitempty"` Emoji string `bson:"emoji,omitempty" json:"emoji,omitempty"`
} }
type TelegramTextRequest struct {
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"`
Text string `bson:"text,omitempty" json:"text,omitempty"`
ReplyToMessageID string `bson:"replyToMessageId,omitempty" json:"reply_to_message_id,omitempty"`
}
type TelegramWebhookUpdate struct {
UpdateID int64 `bson:"updateId,omitempty" json:"update_id,omitempty"`
Message *TelegramMessage `bson:"message,omitempty" json:"message,omitempty"`
}

View File

@@ -2,7 +2,10 @@ syntax = "proto3";
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging"; option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
// AccountCreatedEvent is published when a new user account is registered.
message AccountCreatedEvent { message AccountCreatedEvent {
// account_ref is the unique reference of the newly created account.
string account_ref = 1; string account_ref = 1;
// verification_token is the one-time token used to verify the email address.
string verification_token = 2; string verification_token = 2;
} }

View File

@@ -43,6 +43,7 @@ message RequestMeta {
common.trace.v1.TraceContext trace = 2; common.trace.v1.TraceContext trace = 2;
} }
// ResponseMeta carries tracing context for fee engine responses.
message ResponseMeta { message ResponseMeta {
common.trace.v1.TraceContext trace = 1; common.trace.v1.TraceContext trace = 1;
} }
@@ -101,6 +102,7 @@ message QuoteFeesRequest {
PolicyOverrides policy = 3; PolicyOverrides policy = 3;
} }
// QuoteFeesResponse returns derived fee lines and the rules that produced them.
message QuoteFeesResponse { message QuoteFeesResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
repeated DerivedPostingLine lines = 2; // derived fee/tax/spread lines repeated DerivedPostingLine lines = 2; // derived fee/tax/spread lines
@@ -117,6 +119,7 @@ message PrecomputeFeesRequest {
int64 ttl_ms = 3; // token validity window int64 ttl_ms = 3; // token validity window
} }
// PrecomputeFeesResponse returns a signed fee token and optional preview lines.
message PrecomputeFeesResponse { message PrecomputeFeesResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
string fee_quote_token = 2; // opaque, signed string fee_quote_token = 2; // opaque, signed
@@ -135,6 +138,7 @@ message ValidateFeeTokenRequest {
string fee_quote_token = 2; string fee_quote_token = 2;
} }
// ValidateFeeTokenResponse returns the validation result and embedded fee data.
message ValidateFeeTokenResponse { message ValidateFeeTokenResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
bool valid = 2; bool valid = 2;

View File

@@ -7,7 +7,8 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/gateway/v1;gateway
import "api/proto/common/money/v1/money.proto"; import "api/proto/common/money/v1/money.proto";
import "api/proto/payments/endpoint/v1/endpoint.proto"; import "api/proto/payments/endpoint/v1/endpoint.proto";
// Operation enumerates gateway-level operations that can be performed on a
// payment method.
enum Operation { enum Operation {
OPERATION_UNSPECIFIED = 0; OPERATION_UNSPECIFIED = 0;
OPERATION_AUTHORIZE = 1; OPERATION_AUTHORIZE = 1;
@@ -126,6 +127,7 @@ message RailCapabilities {
bool can_release = 7; bool can_release = 7;
} }
// LimitsOverride provides per-currency overrides for global limit settings.
message LimitsOverride { message LimitsOverride {
string max_volume = 1; string max_volume = 1;
string min_amount = 2; string min_amount = 2;
@@ -166,6 +168,7 @@ enum OperationResult {
OPERATION_RESULT_CANCELLED = 3; OPERATION_RESULT_CANCELLED = 3;
} }
// OperationError describes a failure returned by a gateway operation.
message OperationError { message OperationError {
string code = 1; string code = 1;
string message = 2; string message = 2;
@@ -173,6 +176,8 @@ message OperationError {
bool should_rollback = 4; bool should_rollback = 4;
} }
// OperationExecutionStatus reports the result of executing a single gateway
// operation, including the settled amount and any error.
message OperationExecutionStatus { message OperationExecutionStatus {
string idempotency_key = 1; string idempotency_key = 1;
string operation_ref = 2; string operation_ref = 2;

View File

@@ -4,10 +4,7 @@ package common.payment.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
// CardNetwork identifies a card payment network (scheme).
// -------------------------
// Card network (payment system)
// -------------------------
enum CardNetwork { enum CardNetwork {
CARD_NETWORK_UNSPECIFIED = 0; CARD_NETWORK_UNSPECIFIED = 0;
CARD_NETWORK_VISA = 1; CARD_NETWORK_VISA = 1;
@@ -19,6 +16,7 @@ enum CardNetwork {
CARD_NETWORK_DISCOVER = 7; CARD_NETWORK_DISCOVER = 7;
} }
// CardFundingType classifies the funding source behind a card.
enum CardFundingType { enum CardFundingType {
CARD_FUNDING_UNSPECIFIED = 0; CARD_FUNDING_UNSPECIFIED = 0;
CARD_FUNDING_DEBIT = 1; CARD_FUNDING_DEBIT = 1;
@@ -26,10 +24,8 @@ enum CardFundingType {
CARD_FUNDING_PREPAID = 3; CARD_FUNDING_PREPAID = 3;
} }
// ------------------------- // RawCardData carries PCI-scope card credentials for tokenisation or
// PCI scope: raw card details // direct processing.
// -------------------------
message RawCardData { message RawCardData {
string pan = 1; string pan = 1;
uint32 exp_month = 2; // 112 uint32 exp_month = 2; // 112
@@ -37,10 +33,8 @@ message RawCardData {
string cvv = 4; // optional; often omitted for payouts string cvv = 4; // optional; often omitted for payouts
} }
// CardMetadata holds non-sensitive display and routing hints derived from
// ------------------------- // card details.
// Safe metadata (display / routing hints)
// -------------------------
message CardMetadata { message CardMetadata {
string masked_pan = 1; // e.g. 411111******1111 string masked_pan = 1; // e.g. 411111******1111
CardNetwork network = 2; // Visa/Mastercard/Mir/... CardNetwork network = 2; // Visa/Mastercard/Mir/...
@@ -49,11 +43,8 @@ message CardMetadata {
string issuer_name = 5; // display only (if known) string issuer_name = 5; // display only (if known)
} }
// CardDetails provides card credentials for a payment operation, either
// ------------------------- // as inline raw data or a reference to a stored payment method.
// Card details
// Either inline credentials OR reference to stored payment method
// -------------------------
message CardDetails { message CardDetails {
string id = 1; string id = 1;
@@ -67,5 +58,3 @@ message CardDetails {
string billing_country = 6; // ISO 3166-1 alpha-2, if you need it per operation string billing_country = 6; // ISO 3166-1 alpha-2, if you need it per operation
} }

View File

@@ -4,8 +4,11 @@ package common.payment.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
// CustomPaymentDetails carries an opaque, gateway-specific payment method
// encoded as JSON bytes.
message CustomPaymentDetails { message CustomPaymentDetails {
// id is the unique identifier for this payment method instance.
string id = 1; string id = 1;
// payment_method_json is the raw JSON payload understood by the target gateway.
bytes payment_method_json = 2; bytes payment_method_json = 2;
} }

View File

@@ -6,11 +6,15 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;payment
import "api/proto/gateway/chain/v1/chain.proto"; import "api/proto/gateway/chain/v1/chain.proto";
// ExternalChainDetails describes an external blockchain address as a
// payment endpoint.
message ExternalChainDetails { message ExternalChainDetails {
// id is the unique identifier for this endpoint instance.
string id = 1; string id = 1;
// asset identifies the on-chain token (network + symbol + contract).
chain.gateway.v1.Asset asset = 2; chain.gateway.v1.Asset asset = 2;
// address is the destination blockchain address.
string address = 3; string address = 3;
// memo is an optional transfer memo or tag required by some chains.
string memo = 4; string memo = 4;
} }

View File

@@ -4,11 +4,14 @@ package common.payment.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
// LedgerDetails identifies an internal ledger account as a payment endpoint.
message LedgerDetails { message LedgerDetails {
// id is the unique identifier for this endpoint instance.
string id = 1; string id = 1;
oneof source { oneof source {
// ledger_account_ref is the direct ledger account reference.
string ledger_account_ref = 2; string ledger_account_ref = 2;
// account_code is a human-readable account code resolved at runtime.
string account_code = 3; string account_code = 3;
} }
} }

View File

@@ -4,8 +4,11 @@ package common.payment.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
// ManagedWalletDetails identifies a platform-managed blockchain wallet as a
// payment endpoint.
message ManagedWalletDetails { message ManagedWalletDetails {
// id is the unique identifier for this endpoint instance.
string id = 1; string id = 1;
// managed_wallet_ref is the reference to the managed wallet record.
string managed_wallet_ref = 2; string managed_wallet_ref = 2;
} }

View File

@@ -4,13 +4,15 @@ package common.payment.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
// ------------------------- // RussianBankDetails carries Russian domestic bank account information for
// Russian bank account details // RUB payouts.
// -------------------------
message RussianBankDetails { message RussianBankDetails {
// id is the unique identifier for this endpoint instance.
string id = 1; string id = 1;
string account_number = 2; // 20 digits // account_number is the 20-digit Russian bank account number.
string bik = 3; // 9 digits string account_number = 2;
// bik is the 9-digit Russian bank identification code.
string bik = 3;
// account_holder_name is the full name of the account holder.
string account_holder_name = 4; string account_holder_name = 4;
} }

View File

@@ -4,14 +4,14 @@ package common.payment.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
// ------------------------- // SepaBankDetails carries SEPA bank account information for EUR transfers.
// SEPA bank account details
// -------------------------
message SepaBankDetails { message SepaBankDetails {
// id is the unique identifier for this endpoint instance.
string id = 1; string id = 1;
string iban = 2; // IBAN // iban is the International Bank Account Number.
string bic = 3; // optional (BIC/SWIFT) string iban = 2;
// bic is the optional BIC/SWIFT code.
string bic = 3;
// account_holder_name is the full name of the account holder.
string account_holder_name = 4; string account_holder_name = 4;
} }

View File

@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/storable/v1;storab
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
// Storable carries common persistence metadata (ID and timestamps).
message Storable { message Storable {
string id = 1; string id = 1;
google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp created_at = 10;

View File

@@ -4,19 +4,30 @@ import "google/protobuf/timestamp.proto";
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging"; option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
// NotificationEvent identifies the type and action of an event for routing.
message NotificationEvent { message NotificationEvent {
string type = 1; // NotificationType // type is the notification category (e.g. "payment", "account").
string action = 2; // NotificationAction string type = 1;
// action is the specific event action (e.g. "created", "settled").
string action = 2;
} }
// EventMetadata carries provenance information for a published event.
message EventMetadata { message EventMetadata {
// sender identifies the originating service.
string sender = 1; string sender = 1;
// message_id is the unique identifier of this event message.
string message_id = 2; string message_id = 2;
// timestamp is the time the event was published.
google.protobuf.Timestamp timestamp = 3; google.protobuf.Timestamp timestamp = 3;
} }
// Envelope wraps a serialised event payload with routing and metadata.
message Envelope { message Envelope {
NotificationEvent event = 2; // Notification event with type and action // event describes the notification type and action for routing.
bytes message_data = 3; // Serialized Protobuf message data NotificationEvent event = 2;
EventMetadata metadata = 4; // Metadata about the event // message_data is the serialised protobuf payload.
bytes message_data = 3;
// metadata carries provenance information about the event.
EventMetadata metadata = 4;
} }

View File

@@ -12,29 +12,47 @@ import "api/proto/common/describable/v1/describable.proto";
// Supported blockchain networks for the managed wallets. // Supported blockchain networks for the managed wallets.
enum ChainNetwork { enum ChainNetwork {
// CHAIN_NETWORK_UNSPECIFIED is the default zero value.
CHAIN_NETWORK_UNSPECIFIED = 0; CHAIN_NETWORK_UNSPECIFIED = 0;
// CHAIN_NETWORK_ETHEREUM_MAINNET is Ethereum layer-1 mainnet.
CHAIN_NETWORK_ETHEREUM_MAINNET = 1; CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
// CHAIN_NETWORK_ARBITRUM_ONE is the Arbitrum One rollup.
CHAIN_NETWORK_ARBITRUM_ONE = 2; CHAIN_NETWORK_ARBITRUM_ONE = 2;
// CHAIN_NETWORK_TRON_MAINNET is the TRON mainnet.
CHAIN_NETWORK_TRON_MAINNET = 4; CHAIN_NETWORK_TRON_MAINNET = 4;
// CHAIN_NETWORK_TRON_NILE is the TRON Nile testnet.
CHAIN_NETWORK_TRON_NILE = 5; CHAIN_NETWORK_TRON_NILE = 5;
// CHAIN_NETWORK_ARBITRUM_SEPOLIA is the Arbitrum Sepolia testnet.
CHAIN_NETWORK_ARBITRUM_SEPOLIA = 6; CHAIN_NETWORK_ARBITRUM_SEPOLIA = 6;
} }
// ManagedWalletStatus represents the lifecycle state of a managed wallet.
enum ManagedWalletStatus { enum ManagedWalletStatus {
// MANAGED_WALLET_STATUS_UNSPECIFIED is the default zero value.
MANAGED_WALLET_STATUS_UNSPECIFIED = 0; MANAGED_WALLET_STATUS_UNSPECIFIED = 0;
// MANAGED_WALLET_ACTIVE means the wallet is open and operational.
MANAGED_WALLET_ACTIVE = 1; MANAGED_WALLET_ACTIVE = 1;
// MANAGED_WALLET_SUSPENDED means the wallet is temporarily disabled.
MANAGED_WALLET_SUSPENDED = 2; MANAGED_WALLET_SUSPENDED = 2;
// MANAGED_WALLET_CLOSED means the wallet is permanently closed.
MANAGED_WALLET_CLOSED = 3; MANAGED_WALLET_CLOSED = 3;
} }
// DepositStatus tracks the confirmation state of an inbound deposit.
enum DepositStatus { enum DepositStatus {
// DEPOSIT_STATUS_UNSPECIFIED is the default zero value.
DEPOSIT_STATUS_UNSPECIFIED = 0; DEPOSIT_STATUS_UNSPECIFIED = 0;
// DEPOSIT_PENDING means the deposit has been observed but not yet confirmed.
DEPOSIT_PENDING = 1; DEPOSIT_PENDING = 1;
// DEPOSIT_CONFIRMED means the deposit has been confirmed on-chain.
DEPOSIT_CONFIRMED = 2; DEPOSIT_CONFIRMED = 2;
// DEPOSIT_FAILED means the deposit could not be confirmed.
DEPOSIT_FAILED = 3; DEPOSIT_FAILED = 3;
} }
// TransferStatus tracks the lifecycle of an outbound transfer.
enum TransferStatus { enum TransferStatus {
// TRANSFER_STATUS_UNSPECIFIED is the default zero value.
TRANSFER_STATUS_UNSPECIFIED = 0; TRANSFER_STATUS_UNSPECIFIED = 0;
TRANSFER_CREATED = 1; // record exists, not started TRANSFER_CREATED = 1; // record exists, not started
@@ -54,6 +72,7 @@ message Asset {
string contract_address = 3; // optional override when multiple contracts exist per chain string contract_address = 3; // optional override when multiple contracts exist per chain
} }
// ManagedWallet represents a platform-managed blockchain wallet.
message ManagedWallet { message ManagedWallet {
string wallet_ref = 1; string wallet_ref = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -67,6 +86,7 @@ message ManagedWallet {
common.describable.v1.Describable describable = 10; common.describable.v1.Describable describable = 10;
} }
// CreateManagedWalletRequest is the request to create a new managed wallet.
message CreateManagedWalletRequest { message CreateManagedWalletRequest {
string idempotency_key = 1; string idempotency_key = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -76,18 +96,22 @@ message CreateManagedWalletRequest {
common.describable.v1.Describable describable = 6; common.describable.v1.Describable describable = 6;
} }
// CreateManagedWalletResponse is the response for CreateManagedWallet.
message CreateManagedWalletResponse { message CreateManagedWalletResponse {
ManagedWallet wallet = 1; ManagedWallet wallet = 1;
} }
// GetManagedWalletRequest is the request to retrieve a wallet by reference.
message GetManagedWalletRequest { message GetManagedWalletRequest {
string wallet_ref = 1; string wallet_ref = 1;
} }
// GetManagedWalletResponse is the response for GetManagedWallet.
message GetManagedWalletResponse { message GetManagedWalletResponse {
ManagedWallet wallet = 1; ManagedWallet wallet = 1;
} }
// ListManagedWalletsRequest is the request to list wallets with optional filters.
message ListManagedWalletsRequest { message ListManagedWalletsRequest {
string organization_ref = 1; string organization_ref = 1;
reserved 2; reserved 2;
@@ -101,11 +125,13 @@ message ListManagedWalletsRequest {
google.protobuf.StringValue owner_ref_filter = 5; google.protobuf.StringValue owner_ref_filter = 5;
} }
// ListManagedWalletsResponse is the response for ListManagedWallets.
message ListManagedWalletsResponse { message ListManagedWalletsResponse {
repeated ManagedWallet wallets = 1; repeated ManagedWallet wallets = 1;
common.pagination.v1.CursorPageResponse page = 2; common.pagination.v1.CursorPageResponse page = 2;
} }
// WalletBalance holds the balance breakdown for a managed wallet.
message WalletBalance { message WalletBalance {
common.money.v1.Money available = 1; common.money.v1.Money available = 1;
common.money.v1.Money pending_inbound = 2; common.money.v1.Money pending_inbound = 2;
@@ -114,20 +140,24 @@ message WalletBalance {
common.money.v1.Money native_available = 5; common.money.v1.Money native_available = 5;
} }
// GetWalletBalanceRequest is the request to retrieve a wallet's balance.
message GetWalletBalanceRequest { message GetWalletBalanceRequest {
string wallet_ref = 1; string wallet_ref = 1;
} }
// GetWalletBalanceResponse is the response for GetWalletBalance.
message GetWalletBalanceResponse { message GetWalletBalanceResponse {
WalletBalance balance = 1; WalletBalance balance = 1;
} }
// ServiceFeeBreakdown describes a single fee line item applied to a transfer.
message ServiceFeeBreakdown { message ServiceFeeBreakdown {
string fee_code = 1; string fee_code = 1;
common.money.v1.Money amount = 2; common.money.v1.Money amount = 2;
string description = 3; string description = 3;
} }
// TransferDestination identifies where a transfer should be sent.
message TransferDestination { message TransferDestination {
oneof destination { oneof destination {
string managed_wallet_ref = 1; string managed_wallet_ref = 1;
@@ -136,6 +166,7 @@ message TransferDestination {
string memo = 3; // chain-specific memo/tag when required by the destination string memo = 3; // chain-specific memo/tag when required by the destination
} }
// Transfer represents an outbound blockchain transfer.
message Transfer { message Transfer {
string transfer_ref = 1; string transfer_ref = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -156,6 +187,7 @@ message Transfer {
string operation_ref = 17; string operation_ref = 17;
} }
// SubmitTransferRequest is the request to submit an outbound transfer.
message SubmitTransferRequest { message SubmitTransferRequest {
string idempotency_key = 1; string idempotency_key = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -169,18 +201,22 @@ message SubmitTransferRequest {
string payment_ref = 10; string payment_ref = 10;
} }
// SubmitTransferResponse is the response for SubmitTransfer.
message SubmitTransferResponse { message SubmitTransferResponse {
Transfer transfer = 1; Transfer transfer = 1;
} }
// GetTransferRequest is the request to retrieve a transfer by reference.
message GetTransferRequest { message GetTransferRequest {
string transfer_ref = 1; string transfer_ref = 1;
} }
// GetTransferResponse is the response for GetTransfer.
message GetTransferResponse { message GetTransferResponse {
Transfer transfer = 1; Transfer transfer = 1;
} }
// ListTransfersRequest is the request to list transfers with optional filters.
message ListTransfersRequest { message ListTransfersRequest {
string source_wallet_ref = 1; string source_wallet_ref = 1;
string destination_wallet_ref = 2; string destination_wallet_ref = 2;
@@ -188,11 +224,13 @@ message ListTransfersRequest {
common.pagination.v1.CursorPageRequest page = 4; common.pagination.v1.CursorPageRequest page = 4;
} }
// ListTransfersResponse is the response for ListTransfers.
message ListTransfersResponse { message ListTransfersResponse {
repeated Transfer transfers = 1; repeated Transfer transfers = 1;
common.pagination.v1.CursorPageResponse page = 2; common.pagination.v1.CursorPageResponse page = 2;
} }
// EstimateTransferFeeRequest is the request to estimate network fees for a transfer.
message EstimateTransferFeeRequest { message EstimateTransferFeeRequest {
string source_wallet_ref = 1; string source_wallet_ref = 1;
TransferDestination destination = 2; TransferDestination destination = 2;
@@ -200,21 +238,25 @@ message EstimateTransferFeeRequest {
Asset asset = 4; Asset asset = 4;
} }
// EstimateTransferFeeResponse is the response for EstimateTransferFee.
message EstimateTransferFeeResponse { message EstimateTransferFeeResponse {
common.money.v1.Money network_fee = 1; common.money.v1.Money network_fee = 1;
string estimation_context = 2; string estimation_context = 2;
} }
// ComputeGasTopUpRequest is the request to calculate the gas top-up needed.
message ComputeGasTopUpRequest { message ComputeGasTopUpRequest {
string wallet_ref = 1; string wallet_ref = 1;
common.money.v1.Money estimated_total_fee = 2; common.money.v1.Money estimated_total_fee = 2;
} }
// ComputeGasTopUpResponse is the response for ComputeGasTopUp.
message ComputeGasTopUpResponse { message ComputeGasTopUpResponse {
common.money.v1.Money topup_amount = 1; common.money.v1.Money topup_amount = 1;
bool cap_hit = 2; bool cap_hit = 2;
} }
// EnsureGasTopUpRequest is the request to top up gas for a wallet if needed.
message EnsureGasTopUpRequest { message EnsureGasTopUpRequest {
string idempotency_key = 1; string idempotency_key = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -227,12 +269,14 @@ message EnsureGasTopUpRequest {
string operation_ref = 9; string operation_ref = 9;
} }
// EnsureGasTopUpResponse is the response for EnsureGasTopUp.
message EnsureGasTopUpResponse { message EnsureGasTopUpResponse {
common.money.v1.Money topup_amount = 1; common.money.v1.Money topup_amount = 1;
bool cap_hit = 2; bool cap_hit = 2;
Transfer transfer = 3; Transfer transfer = 3;
} }
// WalletDepositObservedEvent is emitted when a deposit is detected on-chain.
message WalletDepositObservedEvent { message WalletDepositObservedEvent {
string deposit_ref = 1; string deposit_ref = 1;
string wallet_ref = 2; string wallet_ref = 2;
@@ -245,6 +289,7 @@ message WalletDepositObservedEvent {
google.protobuf.Timestamp observed_at = 9; google.protobuf.Timestamp observed_at = 9;
} }
// TransferStatusChangedEvent is emitted when a transfer changes status.
message TransferStatusChangedEvent { message TransferStatusChangedEvent {
Transfer transfer = 1; Transfer transfer = 1;
string reason = 2; string reason = 2;

View File

@@ -72,10 +72,12 @@ message CardPayoutResponse {
string error_message = 5; string error_message = 5;
} }
// GetCardPayoutStatusRequest fetches the current status of a payout.
message GetCardPayoutStatusRequest { message GetCardPayoutStatusRequest {
string payout_id = 1; string payout_id = 1;
} }
// GetCardPayoutStatusResponse returns the current payout state.
message GetCardPayoutStatusResponse { message GetCardPayoutStatusResponse {
CardPayoutState payout = 1; CardPayoutState payout = 1;
} }
@@ -85,8 +87,10 @@ message CardPayoutStatusChangedEvent {
CardPayoutState payout = 1; CardPayoutState payout = 1;
} }
// ListGatewayInstancesRequest requests all registered gateway instances.
message ListGatewayInstancesRequest {} message ListGatewayInstancesRequest {}
// ListGatewayInstancesResponse returns the available gateway instances.
message ListGatewayInstancesResponse { message ListGatewayInstancesResponse {
repeated common.gateway.v1.GatewayInstanceDescriptor items = 1; repeated common.gateway.v1.GatewayInstanceDescriptor items = 1;
} }

View File

@@ -11,51 +11,89 @@ import "api/proto/common/money/v1/money.proto";
// ===== Enums ===== // ===== Enums =====
// EntryType classifies the kind of journal entry.
enum EntryType { enum EntryType {
// ENTRY_TYPE_UNSPECIFIED is the default zero value.
ENTRY_TYPE_UNSPECIFIED = 0; ENTRY_TYPE_UNSPECIFIED = 0;
// ENTRY_CREDIT records an inbound credit.
ENTRY_CREDIT = 1; ENTRY_CREDIT = 1;
// ENTRY_DEBIT records an outbound debit.
ENTRY_DEBIT = 2; ENTRY_DEBIT = 2;
// ENTRY_TRANSFER records a transfer between accounts.
ENTRY_TRANSFER = 3; ENTRY_TRANSFER = 3;
// ENTRY_FX records a foreign-exchange conversion.
ENTRY_FX = 4; ENTRY_FX = 4;
// ENTRY_FEE records a fee charge.
ENTRY_FEE = 5; ENTRY_FEE = 5;
// ENTRY_ADJUST records a manual adjustment.
ENTRY_ADJUST = 6; ENTRY_ADJUST = 6;
// ENTRY_REVERSE records a reversal of a prior entry.
ENTRY_REVERSE = 7; ENTRY_REVERSE = 7;
} }
// LineType classifies the purpose of a posting line within an entry.
enum LineType { enum LineType {
// LINE_TYPE_UNSPECIFIED is the default zero value.
LINE_TYPE_UNSPECIFIED = 0; LINE_TYPE_UNSPECIFIED = 0;
// LINE_MAIN is the primary posting line.
LINE_MAIN = 1; LINE_MAIN = 1;
// LINE_FEE is a fee posting line.
LINE_FEE = 2; LINE_FEE = 2;
// LINE_SPREAD is an FX spread posting line.
LINE_SPREAD = 3; LINE_SPREAD = 3;
// LINE_REVERSAL is a reversal posting line.
LINE_REVERSAL = 4; LINE_REVERSAL = 4;
} }
// AccountType classifies the fundamental accounting type of an account.
enum AccountType { enum AccountType {
// ACCOUNT_TYPE_UNSPECIFIED is the default zero value.
ACCOUNT_TYPE_UNSPECIFIED = 0; ACCOUNT_TYPE_UNSPECIFIED = 0;
// ACCOUNT_TYPE_ASSET represents an asset account.
ACCOUNT_TYPE_ASSET = 1; ACCOUNT_TYPE_ASSET = 1;
// ACCOUNT_TYPE_LIABILITY represents a liability account.
ACCOUNT_TYPE_LIABILITY = 2; ACCOUNT_TYPE_LIABILITY = 2;
// ACCOUNT_TYPE_REVENUE represents a revenue account.
ACCOUNT_TYPE_REVENUE = 3; ACCOUNT_TYPE_REVENUE = 3;
// ACCOUNT_TYPE_EXPENSE represents an expense account.
ACCOUNT_TYPE_EXPENSE = 4; ACCOUNT_TYPE_EXPENSE = 4;
} }
// AccountStatus indicates whether an account is active or frozen.
enum AccountStatus { enum AccountStatus {
// ACCOUNT_STATUS_UNSPECIFIED is the default zero value.
ACCOUNT_STATUS_UNSPECIFIED = 0; ACCOUNT_STATUS_UNSPECIFIED = 0;
// ACCOUNT_STATUS_ACTIVE means the account accepts postings.
ACCOUNT_STATUS_ACTIVE = 1; ACCOUNT_STATUS_ACTIVE = 1;
// ACCOUNT_STATUS_FROZEN means the account is blocked from new postings.
ACCOUNT_STATUS_FROZEN = 2; ACCOUNT_STATUS_FROZEN = 2;
} }
// AccountRole defines the functional role of an account within an organization.
enum AccountRole { enum AccountRole {
// ACCOUNT_ROLE_UNSPECIFIED is the default zero value.
ACCOUNT_ROLE_UNSPECIFIED = 0; ACCOUNT_ROLE_UNSPECIFIED = 0;
// ACCOUNT_ROLE_OPERATING is the main operating account.
ACCOUNT_ROLE_OPERATING = 1; ACCOUNT_ROLE_OPERATING = 1;
// ACCOUNT_ROLE_HOLD is a temporary hold account.
ACCOUNT_ROLE_HOLD = 2; ACCOUNT_ROLE_HOLD = 2;
// ACCOUNT_ROLE_TRANSIT is an in-transit account.
ACCOUNT_ROLE_TRANSIT = 3; ACCOUNT_ROLE_TRANSIT = 3;
// ACCOUNT_ROLE_SETTLEMENT is a settlement account.
ACCOUNT_ROLE_SETTLEMENT = 4; ACCOUNT_ROLE_SETTLEMENT = 4;
// ACCOUNT_ROLE_CLEARING is a clearing account.
ACCOUNT_ROLE_CLEARING = 5; ACCOUNT_ROLE_CLEARING = 5;
// ACCOUNT_ROLE_PENDING is a pending-settlement account.
ACCOUNT_ROLE_PENDING = 6; ACCOUNT_ROLE_PENDING = 6;
// ACCOUNT_ROLE_RESERVE is a reserve account.
ACCOUNT_ROLE_RESERVE = 7; ACCOUNT_ROLE_RESERVE = 7;
// ACCOUNT_ROLE_LIQUIDITY is a liquidity pool account.
ACCOUNT_ROLE_LIQUIDITY = 8; ACCOUNT_ROLE_LIQUIDITY = 8;
// ACCOUNT_ROLE_FEE is a fee collection account.
ACCOUNT_ROLE_FEE = 9; ACCOUNT_ROLE_FEE = 9;
// ACCOUNT_ROLE_CHARGEBACK is a chargeback account.
ACCOUNT_ROLE_CHARGEBACK = 10; ACCOUNT_ROLE_CHARGEBACK = 10;
// ACCOUNT_ROLE_ADJUSTMENT is an adjustment account.
ACCOUNT_ROLE_ADJUSTMENT = 11; ACCOUNT_ROLE_ADJUSTMENT = 11;
} }
@@ -87,6 +125,7 @@ message PostingLine {
// ===== Requests/Responses ===== // ===== Requests/Responses =====
// CreateAccountRequest is the request to create a new ledger account.
message CreateAccountRequest { message CreateAccountRequest {
string organization_ref = 1; string organization_ref = 1;
string owner_ref = 2; string owner_ref = 2;
@@ -103,6 +142,7 @@ message CreateAccountRequest {
AccountRole role = 11; AccountRole role = 11;
} }
// CreateAccountResponse is the response for CreateAccount.
message CreateAccountResponse { message CreateAccountResponse {
LedgerAccount account = 1; LedgerAccount account = 1;
} }
@@ -121,6 +161,7 @@ message PostCreditRequest {
AccountRole role = 10; // optional: assert target account has this role AccountRole role = 10; // optional: assert target account has this role
} }
// PostDebitRequest is the request to post a debit entry.
message PostDebitRequest { message PostDebitRequest {
string idempotency_key = 1; string idempotency_key = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -134,6 +175,7 @@ message PostDebitRequest {
AccountRole role = 10; // optional: assert target account has this role AccountRole role = 10; // optional: assert target account has this role
} }
// TransferRequest is the request to transfer funds between two ledger accounts.
message TransferRequest { message TransferRequest {
string idempotency_key = 1; string idempotency_key = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -148,6 +190,7 @@ message TransferRequest {
AccountRole to_role = 11; AccountRole to_role = 11;
} }
// FXRequest is the request to post a foreign-exchange conversion entry.
message FXRequest { message FXRequest {
string idempotency_key = 1; string idempotency_key = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -164,6 +207,7 @@ message FXRequest {
google.protobuf.Timestamp event_time = 11; google.protobuf.Timestamp event_time = 11;
} }
// PostResponse is the common response returned after any posting operation.
message PostResponse { message PostResponse {
string journal_entry_ref = 1; string journal_entry_ref = 1;
int64 version = 2; // ledger's entry version (monotonic per scope) int64 version = 2; // ledger's entry version (monotonic per scope)
@@ -172,10 +216,12 @@ message PostResponse {
// ---- Balances & Entries ---- // ---- Balances & Entries ----
// GetBalanceRequest is the request to retrieve an account balance.
message GetBalanceRequest { message GetBalanceRequest {
string ledger_account_ref = 1; string ledger_account_ref = 1;
} }
// BalanceResponse holds the current balance of a ledger account.
message BalanceResponse { message BalanceResponse {
string ledger_account_ref = 1; string ledger_account_ref = 1;
common.money.v1.Money balance = 2; common.money.v1.Money balance = 2;
@@ -183,10 +229,12 @@ message BalanceResponse {
google.protobuf.Timestamp last_updated = 4; google.protobuf.Timestamp last_updated = 4;
} }
// GetEntryRequest is the request to retrieve a journal entry by reference.
message GetEntryRequest { message GetEntryRequest {
string entry_ref = 1; string entry_ref = 1;
} }
// JournalEntryResponse represents a complete journal entry with all posting lines.
message JournalEntryResponse { message JournalEntryResponse {
string entry_ref = 1; string entry_ref = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -199,17 +247,20 @@ message JournalEntryResponse {
repeated string ledger_account_refs = 9; // denormalized set for client-side filtering repeated string ledger_account_refs = 9; // denormalized set for client-side filtering
} }
// GetStatementRequest is the request to retrieve paginated journal entries for an account.
message GetStatementRequest { message GetStatementRequest {
string ledger_account_ref = 1; string ledger_account_ref = 1;
string cursor = 2; // opaque string cursor = 2; // opaque
int32 limit = 3; // page size int32 limit = 3; // page size
} }
// StatementResponse is a paginated list of journal entries.
message StatementResponse { message StatementResponse {
repeated JournalEntryResponse entries = 1; repeated JournalEntryResponse entries = 1;
string next_cursor = 2; string next_cursor = 2;
} }
// ListAccountsRequest is the request to list ledger accounts with optional filters.
message ListAccountsRequest { message ListAccountsRequest {
string organization_ref = 1; string organization_ref = 1;
// Optional owner filter with 3-state semantics: // Optional owner filter with 3-state semantics:
@@ -219,28 +270,33 @@ message ListAccountsRequest {
google.protobuf.StringValue owner_ref_filter = 2; google.protobuf.StringValue owner_ref_filter = 2;
} }
// ListAccountsResponse is the response for ListAccounts.
message ListAccountsResponse { message ListAccountsResponse {
repeated LedgerAccount accounts = 1; repeated LedgerAccount accounts = 1;
} }
// ---- Account status mutations ---- // ---- Account status mutations ----
// BlockAccountRequest is the request to freeze (block) a ledger account.
message BlockAccountRequest { message BlockAccountRequest {
string ledger_account_ref = 1; string ledger_account_ref = 1;
string organization_ref = 2; string organization_ref = 2;
AccountRole role = 3; // optional: assert account has this role before blocking AccountRole role = 3; // optional: assert account has this role before blocking
} }
// BlockAccountResponse is the response for BlockAccount.
message BlockAccountResponse { message BlockAccountResponse {
LedgerAccount account = 1; LedgerAccount account = 1;
} }
// UnblockAccountRequest is the request to unfreeze (unblock) a ledger account.
message UnblockAccountRequest { message UnblockAccountRequest {
string ledger_account_ref = 1; string ledger_account_ref = 1;
string organization_ref = 2; string organization_ref = 2;
AccountRole role = 3; // optional: assert account has this role before unblocking AccountRole role = 3; // optional: assert account has this role before unblocking
} }
// UnblockAccountResponse is the response for UnblockAccount.
message UnblockAccountResponse { message UnblockAccountResponse {
LedgerAccount account = 1; LedgerAccount account = 1;
} }

View File

@@ -4,10 +4,17 @@ option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
import "operation_result.proto"; import "operation_result.proto";
// NotificationSentEvent is published after a notification has been delivered
// (or delivery has failed) to a user.
message NotificationSentEvent { message NotificationSentEvent {
// user_id identifies the recipient.
string user_id = 1; string user_id = 1;
// template_id is the notification template that was rendered.
string template_id = 2; string template_id = 2;
// channel is the delivery channel (e.g. "email", "sms", "push").
string channel = 3; string channel = 3;
// locale is the language/region used for rendering (e.g. "en", "ru").
string locale = 4; string locale = 4;
// status reports whether the delivery succeeded.
OperationResult status = 5; OperationResult status = 5;
} }

View File

@@ -2,7 +2,11 @@ syntax = "proto3";
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging"; option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
// ObjectUpdatedEvent is a generic event published when any domain object is
// modified, carrying the object reference and the acting user.
message ObjectUpdatedEvent { message ObjectUpdatedEvent {
// object_ref is the unique reference of the updated object.
string object_ref = 1; string object_ref = 1;
// actor_account_ref identifies the account that performed the update.
string actor_account_ref = 2; string actor_account_ref = 2;
} }

View File

@@ -2,7 +2,11 @@ syntax = "proto3";
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging"; option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
// OperationResult reports the success or failure of an asynchronous operation.
message OperationResult { message OperationResult {
// is_successful is true when the operation completed without errors.
bool is_successful = 1; bool is_successful = 1;
// error_description contains a human-readable error message when
// is_successful is false.
string error_description = 2; string error_description = 2;
} }

View File

@@ -9,7 +9,7 @@ import "api/proto/common/money/v1/money.proto";
import "api/proto/common/fx/v1/fx.proto"; import "api/proto/common/fx/v1/fx.proto";
import "api/proto/common/trace/v1/trace.proto"; import "api/proto/common/trace/v1/trace.proto";
// RateSnapshot holds a point-in-time rate observation from a provider.
message RateSnapshot { message RateSnapshot {
common.fx.v1.CurrencyPair pair = 1; common.fx.v1.CurrencyPair pair = 1;
common.money.v1.Decimal mid = 2; common.money.v1.Decimal mid = 2;
@@ -21,6 +21,7 @@ message RateSnapshot {
common.money.v1.Decimal spread_bps = 8; common.money.v1.Decimal spread_bps = 8;
} }
// RequestMeta carries caller identity and tracing context for oracle requests.
message RequestMeta { message RequestMeta {
reserved 1, 4, 5; reserved 1, 4, 5;
reserved "request_ref", "idempotency_key", "trace_ref"; reserved "request_ref", "idempotency_key", "trace_ref";
@@ -30,6 +31,7 @@ message RequestMeta {
common.trace.v1.TraceContext trace = 6; common.trace.v1.TraceContext trace = 6;
} }
// ResponseMeta carries tracing context for oracle responses.
message ResponseMeta { message ResponseMeta {
reserved 1, 2; reserved 1, 2;
reserved "request_ref", "trace_ref"; reserved "request_ref", "trace_ref";
@@ -37,6 +39,7 @@ message ResponseMeta {
common.trace.v1.TraceContext trace = 3; common.trace.v1.TraceContext trace = 3;
} }
// Quote represents a priced FX quote with an expiry window.
message Quote { message Quote {
string quote_ref = 1; string quote_ref = 1;
common.fx.v1.CurrencyPair pair = 2; common.fx.v1.CurrencyPair pair = 2;
@@ -51,6 +54,7 @@ message Quote {
google.protobuf.Timestamp priced_at = 11; google.protobuf.Timestamp priced_at = 11;
} }
// GetQuoteRequest is the request to obtain an FX quote.
message GetQuoteRequest { message GetQuoteRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
common.fx.v1.CurrencyPair pair = 2; common.fx.v1.CurrencyPair pair = 2;
@@ -65,16 +69,19 @@ message GetQuoteRequest {
int32 max_age_ms = 9; int32 max_age_ms = 9;
} }
// GetQuoteResponse is the response for GetQuote.
message GetQuoteResponse { message GetQuoteResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
Quote quote = 2; Quote quote = 2;
} }
// ValidateQuoteRequest is the request to check whether a quote is still valid.
message ValidateQuoteRequest { message ValidateQuoteRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string quote_ref = 2; string quote_ref = 2;
} }
// ValidateQuoteResponse is the response for ValidateQuote.
message ValidateQuoteResponse { message ValidateQuoteResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
Quote quote = 2; Quote quote = 2;
@@ -82,48 +89,61 @@ message ValidateQuoteResponse {
string reason = 4; string reason = 4;
} }
// ConsumeQuoteRequest marks a quote as used, linking it to a ledger transaction.
message ConsumeQuoteRequest { message ConsumeQuoteRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string quote_ref = 2; string quote_ref = 2;
string ledger_txn_ref = 3; string ledger_txn_ref = 3;
} }
// ConsumeQuoteResponse is the response for ConsumeQuote.
message ConsumeQuoteResponse { message ConsumeQuoteResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
bool consumed = 2; bool consumed = 2;
string reason = 3; string reason = 3;
} }
// LatestRateRequest is the request to fetch the most recent rate for a pair.
message LatestRateRequest { message LatestRateRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
common.fx.v1.CurrencyPair pair = 2; common.fx.v1.CurrencyPair pair = 2;
string provider = 3; string provider = 3;
} }
// LatestRateResponse is the response for LatestRate.
message LatestRateResponse { message LatestRateResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
RateSnapshot rate = 2; RateSnapshot rate = 2;
} }
// ListPairsRequest is the request to list all supported currency pairs.
message ListPairsRequest { message ListPairsRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
} }
// PairMeta holds metadata for a supported currency pair.
message PairMeta { message PairMeta {
common.fx.v1.CurrencyPair pair = 1; common.fx.v1.CurrencyPair pair = 1;
common.money.v1.CurrencyMeta base_meta = 2; common.money.v1.CurrencyMeta base_meta = 2;
common.money.v1.CurrencyMeta quote_meta = 3; common.money.v1.CurrencyMeta quote_meta = 3;
} }
// ListPairsResponse is the response for ListPairs.
message ListPairsResponse { message ListPairsResponse {
ResponseMeta meta = 1; ResponseMeta meta = 1;
repeated PairMeta pairs = 2; repeated PairMeta pairs = 2;
} }
// Oracle provides FX rate quoting, validation, and consumption.
service Oracle { service Oracle {
// GetQuote returns a priced FX quote for a currency pair.
rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse); rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse);
// ValidateQuote checks whether an existing quote is still valid.
rpc ValidateQuote(ValidateQuoteRequest) returns (ValidateQuoteResponse); rpc ValidateQuote(ValidateQuoteRequest) returns (ValidateQuoteResponse);
// ConsumeQuote marks a quote as consumed and links it to a ledger transaction.
rpc ConsumeQuote(ConsumeQuoteRequest) returns (ConsumeQuoteResponse); rpc ConsumeQuote(ConsumeQuoteRequest) returns (ConsumeQuoteResponse);
// LatestRate returns the most recent rate snapshot for a currency pair.
rpc LatestRate(LatestRateRequest) returns (LatestRateResponse); rpc LatestRate(LatestRateRequest) returns (LatestRateResponse);
// ListPairs returns all supported currency pairs.
rpc ListPairs(ListPairsRequest) returns (ListPairsResponse); rpc ListPairs(ListPairsRequest) returns (ListPairsResponse);
} }

View File

@@ -2,7 +2,11 @@ syntax = "proto3";
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging"; option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
// PasswordResetEvent is published when a user requests a password reset.
message PasswordResetEvent { message PasswordResetEvent {
// account_ref is the unique reference of the account requesting the reset.
string account_ref = 1; string account_ref = 1;
// reset_token is the one-time token the user must present to set a new
// password.
string reset_token = 2; string reset_token = 2;
} }

View File

@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/endpoint/v1;endp
import "api/proto/common/describable/v1/describable.proto"; import "api/proto/common/describable/v1/describable.proto";
import "api/proto/common/permission_bound/v1/pbound.proto"; import "api/proto/common/permission_bound/v1/pbound.proto";
// PaymentMethodType classifies the kind of payment instrument.
enum PaymentMethodType { enum PaymentMethodType {
PAYMENT_METHOD_TYPE_UNSPECIFIED = 0; PAYMENT_METHOD_TYPE_UNSPECIFIED = 0;
PAYMENT_METHOD_TYPE_IBAN = 1; PAYMENT_METHOD_TYPE_IBAN = 1;
@@ -19,6 +20,7 @@ enum PaymentMethodType {
PAYMENT_METHOD_TYPE_ACCOUNT = 8; PAYMENT_METHOD_TYPE_ACCOUNT = 8;
} }
// PaymentMethod represents a stored payment instrument (card, IBAN, wallet, etc.).
message PaymentMethod { message PaymentMethod {
common.describable.v1.Describable describable = 1; common.describable.v1.Describable describable = 1;
string recipient_ref = 2; string recipient_ref = 2;
@@ -27,6 +29,8 @@ message PaymentMethod {
bool is_main = 5; bool is_main = 5;
} }
// PaymentEndpoint resolves a payment destination by reference, inline method,
// or payee lookup.
message PaymentEndpoint { message PaymentEndpoint {
oneof source { oneof source {
string payment_method_ref = 1; string payment_method_ref = 1;
@@ -35,6 +39,8 @@ message PaymentEndpoint {
} }
} }
// PaymentMethodRecord wraps a PaymentMethod with its permission and
// persistence metadata.
message PaymentMethodRecord { message PaymentMethodRecord {
common.pbound.v1.PermissionBound permission_bound = 1; common.pbound.v1.PermissionBound permission_bound = 1;
PaymentMethod payment_method = 2; PaymentMethod payment_method = 2;

View File

@@ -7,25 +7,30 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/methods/v1;metho
import "api/proto/common/pagination/v2/cursor.proto"; import "api/proto/common/pagination/v2/cursor.proto";
import "api/proto/payments/endpoint/v1/endpoint.proto"; import "api/proto/payments/endpoint/v1/endpoint.proto";
// CreatePaymentMethodRequest is the request to create a new payment method.
message CreatePaymentMethodRequest { message CreatePaymentMethodRequest {
string account_ref = 1; string account_ref = 1;
string organization_ref = 2; string organization_ref = 2;
payments.endpoint.v1.PaymentMethod payment_method = 3; payments.endpoint.v1.PaymentMethod payment_method = 3;
} }
// CreatePaymentMethodResponse is the response for CreatePaymentMethod.
message CreatePaymentMethodResponse { message CreatePaymentMethodResponse {
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1; payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
} }
// GetPaymentMethodRequest is the request to retrieve a payment method.
message GetPaymentMethodRequest { message GetPaymentMethodRequest {
string account_ref = 1; string account_ref = 1;
string payment_method_ref = 2; string payment_method_ref = 2;
} }
// GetPaymentMethodResponse is the response for GetPaymentMethod.
message GetPaymentMethodResponse { message GetPaymentMethodResponse {
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1; payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
} }
// GetPaymentMethodPrivateRequest retrieves a payment method without permission checks.
message GetPaymentMethodPrivateRequest { message GetPaymentMethodPrivateRequest {
string organization_ref = 1; string organization_ref = 1;
oneof selector { oneof selector {
@@ -35,33 +40,43 @@ message GetPaymentMethodPrivateRequest {
PrivateEndpoint endpoint = 4; PrivateEndpoint endpoint = 4;
} }
// PrivateEndpoint specifies which side of a payment method to retrieve.
enum PrivateEndpoint { enum PrivateEndpoint {
// PRIVATE_ENDPOINT_UNSPECIFIED is the default zero value.
PRIVATE_ENDPOINT_UNSPECIFIED = 0; PRIVATE_ENDPOINT_UNSPECIFIED = 0;
// PRIVATE_ENDPOINT_SOURCE retrieves the source endpoint.
PRIVATE_ENDPOINT_SOURCE = 1; PRIVATE_ENDPOINT_SOURCE = 1;
// PRIVATE_ENDPOINT_DESTINATION retrieves the destination endpoint.
PRIVATE_ENDPOINT_DESTINATION = 2; PRIVATE_ENDPOINT_DESTINATION = 2;
} }
// GetPaymentMethodPrivateResponse is the response for GetPaymentMethodPrivate.
message GetPaymentMethodPrivateResponse { message GetPaymentMethodPrivateResponse {
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1; payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
} }
// UpdatePaymentMethodRequest is the request to update an existing payment method.
message UpdatePaymentMethodRequest { message UpdatePaymentMethodRequest {
string account_ref = 1; string account_ref = 1;
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 2; payments.endpoint.v1.PaymentMethodRecord payment_method_record = 2;
} }
// UpdatePaymentMethodResponse is the response for UpdatePaymentMethod.
message UpdatePaymentMethodResponse { message UpdatePaymentMethodResponse {
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1; payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
} }
// DeletePaymentMethodRequest is the request to delete a payment method.
message DeletePaymentMethodRequest { message DeletePaymentMethodRequest {
string account_ref = 1; string account_ref = 1;
string payment_method_ref = 2; string payment_method_ref = 2;
bool cascade = 3; bool cascade = 3;
} }
// DeletePaymentMethodResponse is the response for DeletePaymentMethod.
message DeletePaymentMethodResponse {} message DeletePaymentMethodResponse {}
// SetPaymentMethodArchivedRequest is the request to archive or unarchive a payment method.
message SetPaymentMethodArchivedRequest { message SetPaymentMethodArchivedRequest {
string account_ref = 1; string account_ref = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -70,8 +85,10 @@ message SetPaymentMethodArchivedRequest {
bool cascade = 5; bool cascade = 5;
} }
// SetPaymentMethodArchivedResponse is the response for SetPaymentMethodArchived.
message SetPaymentMethodArchivedResponse {} message SetPaymentMethodArchivedResponse {}
// ListPaymentMethodsRequest is the request to list payment methods with optional filters.
message ListPaymentMethodsRequest { message ListPaymentMethodsRequest {
string account_ref = 1; string account_ref = 1;
string organization_ref = 2; string organization_ref = 2;
@@ -79,6 +96,7 @@ message ListPaymentMethodsRequest {
common.pagination.v2.ViewCursor cursor = 4; common.pagination.v2.ViewCursor cursor = 4;
} }
// ListPaymentMethodsResponse is the response for ListPaymentMethods.
message ListPaymentMethodsResponse { message ListPaymentMethodsResponse {
repeated payments.endpoint.v1.PaymentMethodRecord payment_methods = 1; repeated payments.endpoint.v1.PaymentMethodRecord payment_methods = 1;
} }
@@ -91,7 +109,7 @@ service PaymentMethodsService {
rpc GetPaymentMethod(GetPaymentMethodRequest) returns (GetPaymentMethodResponse); rpc GetPaymentMethod(GetPaymentMethodRequest) returns (GetPaymentMethodResponse);
// UpdatePaymentMethod updates an existing payment method. // UpdatePaymentMethod updates an existing payment method.
rpc UpdatePaymentMethod(UpdatePaymentMethodRequest) returns (UpdatePaymentMethodResponse); rpc UpdatePaymentMethod(UpdatePaymentMethodRequest) returns (UpdatePaymentMethodResponse);
// Delete exising payment method // DeletePaymentMethod deletes an existing payment method.
rpc DeletePaymentMethod(DeletePaymentMethodRequest) returns (DeletePaymentMethodResponse); rpc DeletePaymentMethod(DeletePaymentMethodRequest) returns (DeletePaymentMethodResponse);
// SetPaymentMethodArchived sets the archived status of a payment method. // SetPaymentMethodArchived sets the archived status of a payment method.
rpc SetPaymentMethodArchived(SetPaymentMethodArchivedRequest) returns (SetPaymentMethodArchivedResponse); rpc SetPaymentMethodArchived(SetPaymentMethodArchivedRequest) returns (SetPaymentMethodArchivedResponse);

View File

@@ -10,6 +10,8 @@ import "api/proto/gateway/chain/v1/chain.proto";
import "api/proto/gateway/mntx/v1/mntx.proto"; import "api/proto/gateway/mntx/v1/mntx.proto";
import "api/proto/payments/shared/v1/shared.proto"; import "api/proto/payments/shared/v1/shared.proto";
// InitiatePaymentsRequest triggers execution of all payment intents within
// a previously accepted quote.
message InitiatePaymentsRequest { message InitiatePaymentsRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -17,10 +19,12 @@ message InitiatePaymentsRequest {
map<string, string> metadata = 4; map<string, string> metadata = 4;
} }
// InitiatePaymentsResponse returns the created payments.
message InitiatePaymentsResponse { message InitiatePaymentsResponse {
repeated payments.shared.v1.Payment payments = 1; repeated payments.shared.v1.Payment payments = 1;
} }
// InitiatePaymentRequest creates a single payment from a standalone intent.
message InitiatePaymentRequest { message InitiatePaymentRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -29,19 +33,23 @@ message InitiatePaymentRequest {
string quote_ref = 5; string quote_ref = 5;
} }
// InitiatePaymentResponse returns the created payment.
message InitiatePaymentResponse { message InitiatePaymentResponse {
payments.shared.v1.Payment payment = 1; payments.shared.v1.Payment payment = 1;
} }
// GetPaymentRequest fetches a payment by its reference.
message GetPaymentRequest { message GetPaymentRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string payment_ref = 2; string payment_ref = 2;
} }
// GetPaymentResponse returns the requested payment.
message GetPaymentResponse { message GetPaymentResponse {
payments.shared.v1.Payment payment = 1; payments.shared.v1.Payment payment = 1;
} }
// ListPaymentsRequest queries payments with optional state and endpoint filters.
message ListPaymentsRequest { message ListPaymentsRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
repeated payments.shared.v1.PaymentState filter_states = 2; repeated payments.shared.v1.PaymentState filter_states = 2;
@@ -51,48 +59,63 @@ message ListPaymentsRequest {
string organization_ref = 6; string organization_ref = 6;
} }
// ListPaymentsResponse returns a page of matching payments.
message ListPaymentsResponse { message ListPaymentsResponse {
repeated payments.shared.v1.Payment payments = 1; repeated payments.shared.v1.Payment payments = 1;
common.pagination.v1.CursorPageResponse page = 2; common.pagination.v1.CursorPageResponse page = 2;
} }
// CancelPaymentRequest requests cancellation of a payment that has not yet
// been settled.
message CancelPaymentRequest { message CancelPaymentRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string payment_ref = 2; string payment_ref = 2;
string reason = 3; string reason = 3;
} }
// CancelPaymentResponse returns the updated payment after cancellation.
message CancelPaymentResponse { message CancelPaymentResponse {
payments.shared.v1.Payment payment = 1; payments.shared.v1.Payment payment = 1;
} }
// ProcessTransferUpdateRequest handles a blockchain transfer status change
// event from the chain gateway.
message ProcessTransferUpdateRequest { message ProcessTransferUpdateRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
chain.gateway.v1.TransferStatusChangedEvent event = 2; chain.gateway.v1.TransferStatusChangedEvent event = 2;
} }
// ProcessTransferUpdateResponse returns the payment after processing.
message ProcessTransferUpdateResponse { message ProcessTransferUpdateResponse {
payments.shared.v1.Payment payment = 1; payments.shared.v1.Payment payment = 1;
} }
// ProcessDepositObservedRequest handles a wallet deposit observation event
// from the chain gateway.
message ProcessDepositObservedRequest { message ProcessDepositObservedRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
chain.gateway.v1.WalletDepositObservedEvent event = 2; chain.gateway.v1.WalletDepositObservedEvent event = 2;
} }
// ProcessDepositObservedResponse returns the payment after processing.
message ProcessDepositObservedResponse { message ProcessDepositObservedResponse {
payments.shared.v1.Payment payment = 1; payments.shared.v1.Payment payment = 1;
} }
// ProcessCardPayoutUpdateRequest handles a card payout status change event
// from the card gateway.
message ProcessCardPayoutUpdateRequest { message ProcessCardPayoutUpdateRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2; mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2;
} }
// ProcessCardPayoutUpdateResponse returns the payment after processing.
message ProcessCardPayoutUpdateResponse { message ProcessCardPayoutUpdateResponse {
payments.shared.v1.Payment payment = 1; payments.shared.v1.Payment payment = 1;
} }
// InitiateConversionRequest creates an FX conversion payment between two
// ledger endpoints.
message InitiateConversionRequest { message InitiateConversionRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -103,18 +126,30 @@ message InitiateConversionRequest {
map<string, string> metadata = 7; map<string, string> metadata = 7;
} }
// InitiateConversionResponse returns the created conversion payment.
message InitiateConversionResponse { message InitiateConversionResponse {
payments.shared.v1.Payment conversion = 1; payments.shared.v1.Payment conversion = 1;
} }
// PaymentExecutionService orchestrates payment lifecycle operations across
// ledger, blockchain, card, and FX rails.
service PaymentExecutionService { service PaymentExecutionService {
// InitiatePayments executes all intents within a quote.
rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse); rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse);
// InitiatePayment creates and executes a single payment.
rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse);
// CancelPayment cancels a pending payment.
rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse);
// GetPayment retrieves a payment by reference.
rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse);
// ListPayments queries payments with filters and pagination.
rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse); rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse);
// InitiateConversion creates an FX conversion payment.
rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse);
// ProcessTransferUpdate handles blockchain transfer status callbacks.
rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse);
// ProcessDepositObserved handles deposit observation callbacks.
rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse);
// ProcessCardPayoutUpdate handles card payout status callbacks.
rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse); rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse);
} }

View File

@@ -0,0 +1,193 @@
syntax = "proto3";
package payments.orchestration.v2;
option go_package = "github.com/tech/sendico/pkg/proto/payments/orchestration/v2;orchestrationv2";
import "google/protobuf/timestamp.proto";
import "api/proto/common/gateway/v1/gateway.proto";
import "api/proto/common/pagination/v1/cursor.proto";
import "api/proto/payments/shared/v1/shared.proto";
import "api/proto/payments/quotation/v2/quotation.proto";
import "api/proto/payments/quotation/v2/interface.proto";
// PaymentOrchestratorService executes quotation-backed payments and exposes
// orchestration-focused read APIs.
//
// Notes:
// - Execution input is quotation_ref (not an execution plan).
// - Returned Payment contains immutable quote/intent snapshots, so reads remain
// meaningful after quote storage TTL expiry.
service PaymentOrchestratorService {
// ExecutePayment creates/starts payment execution from an accepted quote.
rpc ExecutePayment(ExecutePaymentRequest) returns (ExecutePaymentResponse);
// GetPayment returns one payment by reference.
rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse);
// ListPayments returns payments filtered by orchestration lifecycle.
rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse);
}
// ExecutePaymentRequest starts orchestration for one accepted quote.
message ExecutePaymentRequest {
// Organization and trace context; idempotency should be supplied via
// meta.trace.idempotency_key.
payments.shared.v1.RequestMeta meta = 1;
// Required accepted quotation reference.
string quotation_ref = 2;
// Optional caller-side correlation key.
string client_payment_ref = 3;
}
// ExecutePaymentResponse returns the created or deduplicated payment.
message ExecutePaymentResponse {
Payment payment = 1;
}
// GetPaymentRequest fetches one payment by payment_ref.
message GetPaymentRequest {
payments.shared.v1.RequestMeta meta = 1;
string payment_ref = 2;
}
// GetPaymentResponse returns one orchestration payment aggregate.
message GetPaymentResponse {
Payment payment = 1;
}
// ListPaymentsRequest lists payments within the caller organization scope.
message ListPaymentsRequest {
payments.shared.v1.RequestMeta meta = 1;
// Optional state filter. Empty means all states.
repeated OrchestrationState states = 2;
// Optional filter by source quotation.
string quotation_ref = 3;
// Optional creation-time range filter.
// Semantics: created_from is inclusive, created_to is exclusive.
google.protobuf.Timestamp created_from = 4;
google.protobuf.Timestamp created_to = 5;
// Cursor pagination controls.
common.pagination.v1.CursorPageRequest page = 6;
}
// ListPaymentsResponse returns one cursor page of payments.
message ListPaymentsResponse {
repeated Payment payments = 1;
common.pagination.v1.CursorPageResponse page = 2;
}
// Payment is the orchestration runtime aggregate.
// It is designed to be self-contained for post-factum analysis and support.
message Payment {
// Stable payment reference.
string payment_ref = 1;
// Quote used to initiate the payment.
string quotation_ref = 2;
// Immutable snapshot of the execution intent used to create this payment.
payments.quotation.v2.QuoteIntent intent_snapshot = 3;
// Immutable quote snapshot used for execution pricing/route context.
payments.quotation.v2.PaymentQuote quote_snapshot = 4;
string client_payment_ref = 5;
// Current orchestration runtime state.
OrchestrationState state = 6;
// Monotonic aggregate version for optimistic concurrency control.
uint64 version = 7;
// Step-level execution telemetry.
repeated StepExecution step_executions = 8;
// Aggregate timestamps.
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
}
// Kept local on purpose: payments.shared.v1.PaymentState models product-level
// payment lifecycle and does not cover orchestration runtime states.
enum OrchestrationState {
// Default zero value.
ORCHESTRATION_STATE_UNSPECIFIED = 0;
// Payment record created, execution not started yet.
ORCHESTRATION_STATE_CREATED = 1;
// Runtime is actively executing steps.
ORCHESTRATION_STATE_EXECUTING = 2;
// Runtime requires operator/system attention.
ORCHESTRATION_STATE_NEEDS_ATTENTION = 3;
// Execution finished successfully.
ORCHESTRATION_STATE_SETTLED = 4;
// Execution reached terminal failure.
ORCHESTRATION_STATE_FAILED = 5;
}
// StepExecution is telemetry for one orchestration step attempt stream.
message StepExecution {
// Stable step reference inside orchestration runtime.
string step_ref = 1;
// Logical step code/type label (planner/executor-defined).
string step_code = 2;
// Current state of this step.
StepExecutionState state = 3;
// Monotonic attempt number, starts at 1.
uint32 attempt = 4;
// Step timing.
google.protobuf.Timestamp started_at = 5;
google.protobuf.Timestamp completed_at = 6;
// Failure details when state is FAILED/NEEDS_ATTENTION.
Failure failure = 7;
// External references produced by the step.
repeated ExternalReference refs = 8;
}
// Kept local on purpose: no shared enum exists for orchestration step runtime.
enum StepExecutionState {
// Default zero value.
STEP_EXECUTION_STATE_UNSPECIFIED = 0;
// Not started yet.
STEP_EXECUTION_STATE_PENDING = 1;
// Currently running.
STEP_EXECUTION_STATE_RUNNING = 2;
// Finished successfully.
STEP_EXECUTION_STATE_COMPLETED = 3;
// Finished with terminal error.
STEP_EXECUTION_STATE_FAILED = 4;
// Blocked and requires attention/intervention.
STEP_EXECUTION_STATE_NEEDS_ATTENTION = 5;
// Not executed because it became irrelevant/unreachable.
STEP_EXECUTION_STATE_SKIPPED = 6;
}
// Failure describes a normalized step failure.
message Failure {
// Broad, shared failure category.
payments.shared.v1.PaymentFailureCode category = 1;
// Machine-readable, executor-specific code.
string code = 2;
// Human-readable message.
string message = 3;
}
// ExternalReference links step execution to external systems/operations.
message ExternalReference {
// Rail where external side effect happened.
common.gateway.v1.Rail rail = 1;
// Gateway instance that owns the referenced operation/entity.
// This is the discovery key for fetching details on demand.
string gateway_instance_id = 2;
// Reference classifier. Keep values stable and namespaced:
// e.g. "ledger.journal", "ledger.hold", "chain.tx", "provider.payout".
string kind = 3;
// External operation/entity reference id in the owner system.
string ref = 4;
}

View File

@@ -6,15 +6,15 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/payment/v1;payme
import "api/proto/payments/transfer/v1/transfer.proto"; import "api/proto/payments/transfer/v1/transfer.proto";
// PaymentIntent describes the full intent for an external payment,
// ------------------------- // wrapping a transfer with payer/payee identity and purpose.
// External payment semantics
// -------------------------
message PaymentIntent { message PaymentIntent {
// transfer is the underlying value movement.
payments.transfer.v1.TransferIntent transfer = 1; payments.transfer.v1.TransferIntent transfer = 1;
// payer_ref identifies the entity funding the payment.
string payer_ref = 2; string payer_ref = 2;
// payee_ref identifies the payment beneficiary.
string payee_ref = 3; string payee_ref = 3;
// purpose is a human-readable description of the payment reason.
string purpose = 4; string purpose = 4;
} }

View File

@@ -6,7 +6,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v1;quo
import "api/proto/payments/shared/v1/shared.proto"; import "api/proto/payments/shared/v1/shared.proto";
// QuotePaymentRequest is the request to quote a single payment.
message QuotePaymentRequest { message QuotePaymentRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -14,6 +14,7 @@ message QuotePaymentRequest {
bool preview_only = 4; bool preview_only = 4;
} }
// QuotePaymentResponse is the response for QuotePayment.
message QuotePaymentResponse { message QuotePaymentResponse {
payments.shared.v1.PaymentQuote quote = 1; payments.shared.v1.PaymentQuote quote = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -21,6 +22,7 @@ message QuotePaymentResponse {
string execution_note = 3; string execution_note = 3;
} }
// QuotePaymentsRequest is the request to quote multiple payments in a batch.
message QuotePaymentsRequest { message QuotePaymentsRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -28,6 +30,7 @@ message QuotePaymentsRequest {
bool preview_only = 4; bool preview_only = 4;
} }
// QuotePaymentsResponse is the response for QuotePayments.
message QuotePaymentsResponse { message QuotePaymentsResponse {
string quote_ref = 1; string quote_ref = 1;
payments.shared.v1.PaymentQuoteAggregate aggregate = 2; payments.shared.v1.PaymentQuoteAggregate aggregate = 2;
@@ -35,6 +38,7 @@ message QuotePaymentsResponse {
string idempotency_key = 4; string idempotency_key = 4;
} }
// QuotationService provides payment quoting capabilities.
service QuotationService { service QuotationService {
// QuotePayment returns a quote for a single payment request. // QuotePayment returns a quote for a single payment request.
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);

View File

@@ -12,6 +12,7 @@ import "api/proto/common/payment/v1/settlement.proto";
import "api/proto/billing/fees/v1/fees.proto"; import "api/proto/billing/fees/v1/fees.proto";
import "api/proto/oracle/v1/oracle.proto"; import "api/proto/oracle/v1/oracle.proto";
// QuoteState tracks the lifecycle of a payment quote.
enum QuoteState { enum QuoteState {
QUOTE_STATE_UNSPECIFIED = 0; QUOTE_STATE_UNSPECIFIED = 0;
QUOTE_STATE_INDICATIVE = 1; QUOTE_STATE_INDICATIVE = 1;
@@ -20,6 +21,7 @@ enum QuoteState {
QUOTE_STATE_EXPIRED = 4; QUOTE_STATE_EXPIRED = 4;
} }
// QuoteBlockReason explains why a quote cannot be executed.
enum QuoteBlockReason { enum QuoteBlockReason {
QUOTE_BLOCK_REASON_UNSPECIFIED = 0; QUOTE_BLOCK_REASON_UNSPECIFIED = 0;
QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1; QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1;
@@ -31,6 +33,7 @@ enum QuoteBlockReason {
QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE = 7; QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE = 7;
} }
// QuoteExecutionReadiness indicates how readily a quote can be executed.
enum QuoteExecutionReadiness { enum QuoteExecutionReadiness {
QUOTE_EXECUTION_READINESS_UNSPECIFIED = 0; QUOTE_EXECUTION_READINESS_UNSPECIFIED = 0;
QUOTE_EXECUTION_READINESS_LIQUIDITY_READY = 1; QUOTE_EXECUTION_READINESS_LIQUIDITY_READY = 1;
@@ -38,6 +41,7 @@ enum QuoteExecutionReadiness {
QUOTE_EXECUTION_READINESS_INDICATIVE = 3; QUOTE_EXECUTION_READINESS_INDICATIVE = 3;
} }
// RouteHopRole classifies a hop's position in the payment route.
enum RouteHopRole { enum RouteHopRole {
ROUTE_HOP_ROLE_UNSPECIFIED = 0; ROUTE_HOP_ROLE_UNSPECIFIED = 0;
ROUTE_HOP_ROLE_SOURCE = 1; ROUTE_HOP_ROLE_SOURCE = 1;
@@ -45,12 +49,14 @@ enum RouteHopRole {
ROUTE_HOP_ROLE_DESTINATION = 3; ROUTE_HOP_ROLE_DESTINATION = 3;
} }
// FeeTreatment determines how fees are applied to the transfer amount.
enum FeeTreatment { enum FeeTreatment {
FEE_TREATMENT_UNSPECIFIED = 0; FEE_TREATMENT_UNSPECIFIED = 0;
FEE_TREATMENT_ADD_TO_SOURCE = 1; FEE_TREATMENT_ADD_TO_SOURCE = 1;
FEE_TREATMENT_DEDUCT_FROM_DESTINATION = 2; FEE_TREATMENT_DEDUCT_FROM_DESTINATION = 2;
} }
// RouteHop represents a single step in the payment route topology.
message RouteHop { message RouteHop {
uint32 index = 1; uint32 index = 1;
string rail = 2; string rail = 2;
@@ -60,6 +66,7 @@ message RouteHop {
RouteHopRole role = 6; RouteHopRole role = 6;
} }
// RouteSettlement describes the settlement asset and model for a route.
message RouteSettlement { message RouteSettlement {
common.payment.v1.ChainAsset asset = 1; common.payment.v1.ChainAsset asset = 1;
string model = 2; string model = 2;
@@ -91,6 +98,7 @@ message ExecutionConditions {
repeated string assumptions = 7; repeated string assumptions = 7;
} }
// PaymentQuote is a priced, time-bound quote for a single payment intent.
message PaymentQuote { message PaymentQuote {
common.storable.v1.Storable storable = 1; common.storable.v1.Storable storable = 1;
QuoteState state = 2; QuoteState state = 2;

View File

@@ -10,6 +10,7 @@ import "api/proto/common/payment/v1/settlement.proto";
import "api/proto/payments/endpoint/v1/endpoint.proto"; import "api/proto/payments/endpoint/v1/endpoint.proto";
import "api/proto/payments/quotation/v2/interface.proto"; import "api/proto/payments/quotation/v2/interface.proto";
// QuoteIntent describes the intent behind a v2 quote request.
message QuoteIntent { message QuoteIntent {
payments.endpoint.v1.PaymentEndpoint source = 1; payments.endpoint.v1.PaymentEndpoint source = 1;
payments.endpoint.v1.PaymentEndpoint destination = 2; payments.endpoint.v1.PaymentEndpoint destination = 2;
@@ -20,6 +21,7 @@ message QuoteIntent {
string comment = 7; string comment = 7;
} }
// QuotePaymentRequest is the request to quote a single v2 payment.
message QuotePaymentRequest { message QuotePaymentRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -28,11 +30,13 @@ message QuotePaymentRequest {
string initiator_ref = 5; string initiator_ref = 5;
} }
// QuotePaymentResponse is the response for QuotePayment.
message QuotePaymentResponse { message QuotePaymentResponse {
payments.quotation.v2.PaymentQuote quote = 1; payments.quotation.v2.PaymentQuote quote = 1;
string idempotency_key = 2; string idempotency_key = 2;
} }
// QuotePaymentsRequest is the request to quote multiple v2 payments in a batch.
message QuotePaymentsRequest { message QuotePaymentsRequest {
payments.shared.v1.RequestMeta meta = 1; payments.shared.v1.RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -41,13 +45,14 @@ message QuotePaymentsRequest {
string initiator_ref = 5; string initiator_ref = 5;
} }
// QuotePaymentsResponse is the response for QuotePayments.
message QuotePaymentsResponse { message QuotePaymentsResponse {
string quote_ref = 1; string quote_ref = 1;
repeated payments.quotation.v2.PaymentQuote quotes = 3; repeated payments.quotation.v2.PaymentQuote quotes = 3;
string idempotency_key = 4; string idempotency_key = 4;
} }
// Quotation service interface // QuotationService provides v2 payment quoting capabilities.
service QuotationService { service QuotationService {
// QuotePayment returns a quote for a single payment request. // QuotePayment returns a quote for a single payment request.
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);

View File

@@ -14,6 +14,7 @@ import "api/proto/billing/fees/v1/fees.proto";
import "api/proto/gateway/chain/v1/chain.proto"; import "api/proto/gateway/chain/v1/chain.proto";
import "api/proto/oracle/v1/oracle.proto"; import "api/proto/oracle/v1/oracle.proto";
// PaymentKind classifies the type of payment operation.
enum PaymentKind { enum PaymentKind {
PAYMENT_KIND_UNSPECIFIED = 0; PAYMENT_KIND_UNSPECIFIED = 0;
PAYMENT_KIND_PAYOUT = 1; PAYMENT_KIND_PAYOUT = 1;
@@ -21,6 +22,7 @@ enum PaymentKind {
PAYMENT_KIND_FX_CONVERSION = 3; PAYMENT_KIND_FX_CONVERSION = 3;
} }
// PaymentState tracks the lifecycle of a payment.
enum PaymentState { enum PaymentState {
PAYMENT_STATE_UNSPECIFIED = 0; PAYMENT_STATE_UNSPECIFIED = 0;
PAYMENT_STATE_ACCEPTED = 1; PAYMENT_STATE_ACCEPTED = 1;
@@ -31,6 +33,7 @@ enum PaymentState {
PAYMENT_STATE_CANCELLED = 6; PAYMENT_STATE_CANCELLED = 6;
} }
// PaymentFailureCode categorises the reason for a payment failure.
enum PaymentFailureCode { enum PaymentFailureCode {
FAILURE_UNSPECIFIED = 0; FAILURE_UNSPECIFIED = 0;
FAILURE_BALANCE = 1; FAILURE_BALANCE = 1;
@@ -41,21 +44,26 @@ enum PaymentFailureCode {
FAILURE_POLICY = 6; FAILURE_POLICY = 6;
} }
// RequestMeta carries organisation context and tracing information for
// every payment service request.
message RequestMeta { message RequestMeta {
string organization_ref = 1; string organization_ref = 1;
common.trace.v1.TraceContext trace = 2; common.trace.v1.TraceContext trace = 2;
} }
// LedgerEndpoint identifies a source or destination on the internal ledger.
message LedgerEndpoint { message LedgerEndpoint {
string ledger_account_ref = 1; string ledger_account_ref = 1;
string contra_ledger_account_ref = 2; string contra_ledger_account_ref = 2;
} }
// ManagedWalletEndpoint identifies a platform-managed blockchain wallet.
message ManagedWalletEndpoint { message ManagedWalletEndpoint {
string managed_wallet_ref = 1; string managed_wallet_ref = 1;
chain.gateway.v1.Asset asset = 2; chain.gateway.v1.Asset asset = 2;
} }
// ExternalChainEndpoint identifies an external blockchain address.
message ExternalChainEndpoint { message ExternalChainEndpoint {
chain.gateway.v1.Asset asset = 1; chain.gateway.v1.Asset asset = 1;
string address = 2; string address = 2;
@@ -76,6 +84,7 @@ message CardEndpoint {
string masked_pan = 8; string masked_pan = 8;
} }
// PaymentEndpoint is a polymorphic endpoint that can target any supported rail.
message PaymentEndpoint { message PaymentEndpoint {
oneof endpoint { oneof endpoint {
LedgerEndpoint ledger = 1; LedgerEndpoint ledger = 1;
@@ -87,6 +96,7 @@ message PaymentEndpoint {
string instance_id = 11; string instance_id = 11;
} }
// FXIntent describes the foreign-exchange requirements for a payment.
message FXIntent { message FXIntent {
common.fx.v1.CurrencyPair pair = 1; common.fx.v1.CurrencyPair pair = 1;
common.fx.v1.Side side = 2; common.fx.v1.Side side = 2;
@@ -96,6 +106,8 @@ message FXIntent {
int32 max_age_ms = 6; int32 max_age_ms = 6;
} }
// PaymentIntent fully describes a payment to be executed, including source,
// destination, amount, FX, fee policy, and settlement preferences.
message PaymentIntent { message PaymentIntent {
PaymentKind kind = 1; PaymentKind kind = 1;
PaymentEndpoint source = 2; PaymentEndpoint source = 2;
@@ -111,6 +123,8 @@ message PaymentIntent {
string ref = 12; string ref = 12;
} }
// Customer holds payer identity and address details for compliance and
// routing purposes.
message Customer { message Customer {
string id = 1; string id = 1;
string first_name = 2; string first_name = 2;
@@ -124,6 +138,8 @@ message Customer {
string address = 10; string address = 10;
} }
// PaymentQuote captures the pricing snapshot for a payment including
// debit amount, expected settlement, fees, and FX details.
message PaymentQuote { message PaymentQuote {
common.money.v1.Money debit_amount = 1; common.money.v1.Money debit_amount = 1;
common.money.v1.Money expected_settlement_amount = 2; common.money.v1.Money expected_settlement_amount = 2;
@@ -136,6 +152,7 @@ message PaymentQuote {
common.money.v1.Money debit_settlement_amount = 9; common.money.v1.Money debit_settlement_amount = 9;
} }
// PaymentQuoteAggregate summarises totals across multiple payment quotes.
message PaymentQuoteAggregate { message PaymentQuoteAggregate {
repeated common.money.v1.Money debit_amounts = 1; repeated common.money.v1.Money debit_amounts = 1;
repeated common.money.v1.Money expected_settlement_amounts = 2; repeated common.money.v1.Money expected_settlement_amounts = 2;
@@ -143,6 +160,8 @@ message PaymentQuoteAggregate {
repeated common.money.v1.Money network_fee_totals = 4; repeated common.money.v1.Money network_fee_totals = 4;
} }
// ExecutionRefs collects cross-service references created during payment
// execution (ledger entries, chain transfers, card payouts).
message ExecutionRefs { message ExecutionRefs {
string debit_entry_ref = 1; string debit_entry_ref = 1;
string credit_entry_ref = 2; string credit_entry_ref = 2;
@@ -152,6 +171,7 @@ message ExecutionRefs {
string fee_transfer_ref = 6; string fee_transfer_ref = 6;
} }
// ExecutionStep describes a single operational step in the legacy execution plan.
message ExecutionStep { message ExecutionStep {
string code = 1; string code = 1;
string description = 2; string description = 2;
@@ -164,11 +184,13 @@ message ExecutionStep {
string operation_ref = 9; string operation_ref = 9;
} }
// ExecutionPlan is the legacy ordered list of steps for fulfilling a payment.
message ExecutionPlan { message ExecutionPlan {
repeated ExecutionStep steps = 1; repeated ExecutionStep steps = 1;
common.money.v1.Money total_network_fee = 2; common.money.v1.Money total_network_fee = 2;
} }
// PaymentStep is a single rail-level operation within a PaymentPlan.
message PaymentStep { message PaymentStep {
common.gateway.v1.Rail rail = 1; common.gateway.v1.Rail rail = 1;
string gateway_id = 2; // required for external rails string gateway_id = 2; // required for external rails
@@ -181,6 +203,8 @@ message PaymentStep {
repeated string commit_after = 9; repeated string commit_after = 9;
} }
// PaymentPlan is the orchestrated sequence of rail-level steps that fulfil
// a payment, including FX and fee lines.
message PaymentPlan { message PaymentPlan {
string id = 1; string id = 1;
repeated PaymentStep steps = 2; repeated PaymentStep steps = 2;
@@ -202,6 +226,8 @@ message CardPayout {
string gateway_reference = 8; string gateway_reference = 8;
} }
// Payment is the top-level aggregate representing a payment throughout its
// lifecycle, from initiation through settlement or failure.
message Payment { message Payment {
string payment_ref = 1; string payment_ref = 1;
string idempotency_key = 2; string idempotency_key = 2;

View File

@@ -7,14 +7,14 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/transfer/v1;tran
import "api/proto/common/money/v1/money.proto"; import "api/proto/common/money/v1/money.proto";
import "api/proto/payments/endpoint/v1/endpoint.proto"; import "api/proto/payments/endpoint/v1/endpoint.proto";
// TransferIntent describes a value movement between two payment endpoints.
// -------------------------
// Base value movement
// -------------------------
message TransferIntent { message TransferIntent {
// source is the originating payment endpoint.
payments.endpoint.v1.PaymentEndpoint source = 1; payments.endpoint.v1.PaymentEndpoint source = 1;
// destination is the receiving payment endpoint.
payments.endpoint.v1.PaymentEndpoint destination = 2; payments.endpoint.v1.PaymentEndpoint destination = 2;
// amount is the monetary value to transfer.
common.money.v1.Money amount = 3; common.money.v1.Money amount = 3;
// comment is an optional human-readable note for the transfer.
string comment = 4; string comment = 4;
} }

View File

@@ -2,7 +2,10 @@ syntax = "proto3";
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging"; option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
// SiteRequestEvent is published when a visitor submits a request through the
// public website (demo, contact, or callback).
message SiteRequestEvent { message SiteRequestEvent {
// RequestType classifies the kind of site request.
enum RequestType { enum RequestType {
REQUEST_TYPE_UNSPECIFIED = 0; REQUEST_TYPE_UNSPECIFIED = 0;
REQUEST_TYPE_DEMO = 1; REQUEST_TYPE_DEMO = 1;
@@ -10,15 +13,20 @@ message SiteRequestEvent {
REQUEST_TYPE_CALL = 3; REQUEST_TYPE_CALL = 3;
} }
// type identifies which kind of request was submitted.
RequestType type = 1; RequestType type = 1;
oneof payload { oneof payload {
// demo is the payload for a product demo request.
SiteDemoRequest demo = 2; SiteDemoRequest demo = 2;
// contact is the payload for a general contact inquiry.
SiteContactRequest contact = 3; SiteContactRequest contact = 3;
// call is the payload for a callback request.
SiteCallRequest call = 4; SiteCallRequest call = 4;
} }
} }
// SiteDemoRequest carries details for a product demo request.
message SiteDemoRequest { message SiteDemoRequest {
string name = 1; string name = 1;
string organization_name = 2; string organization_name = 2;
@@ -28,6 +36,7 @@ message SiteDemoRequest {
string comment = 6; string comment = 6;
} }
// SiteContactRequest carries details for a general contact inquiry.
message SiteContactRequest { message SiteContactRequest {
string name = 1; string name = 1;
string email = 2; string email = 2;
@@ -37,6 +46,7 @@ message SiteContactRequest {
string message = 6; string message = 6;
} }
// SiteCallRequest carries details for a callback request.
message SiteCallRequest { message SiteCallRequest {
string name = 1; string name = 1;
string phone = 2; string phone = 2;