refactored notificatoin / tgsettle responsibility boundaries
This commit is contained in:
@@ -38,6 +38,6 @@ messaging:
|
||||
gateway:
|
||||
rail: "provider_settlement"
|
||||
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
|
||||
timeout_seconds: 259200
|
||||
timeout_seconds: 345600
|
||||
accepted_user_ids: []
|
||||
success_reaction: "\U0001FAE1"
|
||||
|
||||
@@ -38,6 +38,6 @@ messaging:
|
||||
gateway:
|
||||
rail: "provider_settlement"
|
||||
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
|
||||
timeout_seconds: 259200
|
||||
timeout_seconds: 345600
|
||||
accepted_user_ids: []
|
||||
success_reaction: "\U0001FAE1"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
|
||||
@@ -36,8 +37,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfirmationTimeoutSeconds = 259200
|
||||
defaultConfirmationTimeoutSeconds = 345600
|
||||
defaultTelegramSuccessReaction = "\U0001FAE1"
|
||||
defaultConfirmationSweepInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -73,7 +75,10 @@ type Service struct {
|
||||
successReaction string
|
||||
outbox gatewayoutbox.ReliableRuntime
|
||||
|
||||
consumers []msg.Consumer
|
||||
consumers []msg.Consumer
|
||||
timeoutCtx context.Context
|
||||
timeoutCancel context.CancelFunc
|
||||
timeoutWG sync.WaitGroup
|
||||
|
||||
connectorv1.UnimplementedConnectorServiceServer
|
||||
}
|
||||
@@ -103,6 +108,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
}
|
||||
svc.startConsumers()
|
||||
svc.startAnnouncer()
|
||||
svc.startConfirmationTimeoutWatcher()
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -125,6 +131,10 @@ func (s *Service) Shutdown() {
|
||||
consumer.Close()
|
||||
}
|
||||
}
|
||||
if s.timeoutCancel != nil {
|
||||
s.timeoutCancel()
|
||||
}
|
||||
s.timeoutWG.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) startConsumers() {
|
||||
@@ -136,6 +146,10 @@ func (s *Service) startConsumers() {
|
||||
}
|
||||
resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult)
|
||||
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) {
|
||||
@@ -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))
|
||||
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 {
|
||||
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
|
||||
// (or it can be marked as failed — depending on your semantics).
|
||||
// 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
|
||||
}
|
||||
|
||||
if isFinalConfirmationStatus(result.Status) {
|
||||
_ = s.clearPendingConfirmation(ctx, requestID)
|
||||
}
|
||||
|
||||
s.publishTelegramReaction(result)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||
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 {
|
||||
payments *fakePaymentsStore
|
||||
tg *fakeTelegramStore
|
||||
pending *fakePendingStore
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
||||
@@ -71,6 +73,93 @@ func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
|
||||
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 (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
|
||||
//
|
||||
@@ -119,6 +208,7 @@ func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
|
||||
repo := &fakeRepo{
|
||||
payments: &fakePaymentsStore{},
|
||||
tg: &fakeTelegramStore{},
|
||||
pending: &fakePendingStore{},
|
||||
}
|
||||
|
||||
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
|
||||
|
||||
@@ -48,3 +48,18 @@ type TelegramConfirmation struct {
|
||||
RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,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"`
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type Repository struct {
|
||||
|
||||
payments storage.PaymentsStore
|
||||
tg storage.TelegramConfirmationsStore
|
||||
pending storage.PendingConfirmationsStore
|
||||
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"))
|
||||
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)
|
||||
if err != nil {
|
||||
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.tg = tgStore
|
||||
result.pending = pendingStore
|
||||
result.outbox = outboxStore
|
||||
result.logger.Info("Payment gateway MongoDB storage initialised")
|
||||
return result, nil
|
||||
@@ -88,6 +95,10 @@ func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore
|
||||
return r.tg
|
||||
}
|
||||
|
||||
func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore {
|
||||
return r.pending
|
||||
}
|
||||
|
||||
func (r *Repository) Outbox() gatewayoutbox.Store {
|
||||
return r.outbox
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
@@ -12,6 +13,7 @@ var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate reco
|
||||
type Repository interface {
|
||||
Payments() PaymentsStore
|
||||
TelegramConfirmations() TelegramConfirmationsStore
|
||||
PendingConfirmations() PendingConfirmationsStore
|
||||
}
|
||||
|
||||
type PaymentsStore interface {
|
||||
@@ -22,3 +24,13 @@ type PaymentsStore interface {
|
||||
type TelegramConfirmationsStore interface {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/domainprovider"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
na "github.com/tech/sendico/pkg/messaging/notifications/account"
|
||||
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
|
||||
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
|
||||
@@ -27,8 +28,8 @@ type NotificationAPI struct {
|
||||
client mmail.Client
|
||||
dp domainprovider.DomainProvider
|
||||
tg telegram.Client
|
||||
producer msg.Producer
|
||||
announcer *discovery.Announcer
|
||||
confirm *confirmationManager
|
||||
}
|
||||
|
||||
func (a *NotificationAPI) Name() mservice.Type {
|
||||
@@ -39,9 +40,6 @@ func (a *NotificationAPI) Finish(_ context.Context) error {
|
||||
if a.announcer != nil {
|
||||
a.announcer.Stop()
|
||||
}
|
||||
if a.confirm != nil {
|
||||
a.confirm.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,6 +48,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
||||
dp: a.DomainProvider(),
|
||||
}
|
||||
p.logger = a.Logger().Named(p.Name())
|
||||
p.producer = a.Register().Producer()
|
||||
|
||||
if a.Config().Notification == nil {
|
||||
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))
|
||||
return nil, err
|
||||
}
|
||||
p.confirm = newConfirmationManager(p.logger, p.tg, a.Register().Producer())
|
||||
|
||||
db, err := a.DBFactory().NewAccountDB()
|
||||
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))
|
||||
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 {
|
||||
p.logger.Error("Failed to register telegram reaction handler", zap.Error(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))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -16,11 +19,11 @@ func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.R
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if a.confirm == nil {
|
||||
if a.producer == 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
|
||||
}
|
||||
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.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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
package idem
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
)
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,47 @@ func factory(logger mlogger.Logger, file string, debug bool) (server.Application
|
||||
func main() {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -36,11 +36,34 @@ func (crn *ConfirmationResultNotification) Serialize() ([]byte, error) {
|
||||
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 {
|
||||
return model.NewNotification(mservice.Notifications, nm.NAConfirmationRequest)
|
||||
}
|
||||
|
||||
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)
|
||||
if action == "" {
|
||||
action = "unknown"
|
||||
@@ -51,7 +74,7 @@ func confirmationResultEvent(sourceService, rail string) model.NotificationEvent
|
||||
rail = "default"
|
||||
}
|
||||
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 {
|
||||
@@ -75,3 +98,14 @@ func NewConfirmationResultEnvelope(sender string, result *model.ConfirmationResu
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,29 @@ func (crp *ConfirmationResultProcessor) GetSubject() model.NotificationEvent {
|
||||
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 {
|
||||
if logger != nil {
|
||||
logger = logger.Named("confirmation_request_processor")
|
||||
@@ -79,3 +102,14 @@ func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail s
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,40 @@ func telegramReactionEvent() model.NotificationEvent {
|
||||
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 {
|
||||
var payload model.TelegramReactionRequest
|
||||
if request != nil {
|
||||
@@ -36,3 +70,25 @@ func NewTelegramReactionEnvelope(sender string, request *model.TelegramReactionR
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,71 @@ func NewTelegramReactionProcessor(logger mlogger.Logger, handler ch.TelegramReac
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ func ConfirmationResult(sender string, result *model.ConfirmationResult, sourceS
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -9,3 +9,5 @@ import (
|
||||
type ConfirmationRequestHandler = func(context.Context, *model.ConfirmationRequest) error
|
||||
|
||||
type ConfirmationResultHandler = func(context.Context, *model.ConfirmationResult) error
|
||||
|
||||
type ConfirmationDispatchHandler = func(context.Context, *model.ConfirmationRequestDispatch) error
|
||||
|
||||
@@ -7,3 +7,7 @@ import (
|
||||
)
|
||||
|
||||
type TelegramReactionHandler = func(context.Context, *model.TelegramReactionRequest) error
|
||||
|
||||
type TelegramTextHandler = func(context.Context, *model.TelegramTextRequest) error
|
||||
|
||||
type TelegramUpdateHandler = func(context.Context, *model.TelegramWebhookUpdate) error
|
||||
|
||||
@@ -13,6 +13,22 @@ func TelegramReaction(sender string, request *model.TelegramReactionRequest) mes
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -33,3 +33,13 @@ type ConfirmationResult struct {
|
||||
Status ConfirmationStatus `bson:"status,omitempty" json:"status,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"`
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func FromStringImp(s string) (*NotificationEventImp, error) {
|
||||
|
||||
func StringToNotificationAction(s string) (nm.NotificationAction, error) {
|
||||
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
|
||||
default:
|
||||
return "", merrors.DataConflict("invalid Notification action: " + s)
|
||||
|
||||
@@ -14,6 +14,8 @@ const (
|
||||
|
||||
NAConfirmationRequest NotificationAction = "confirmation.request"
|
||||
NATelegramReaction NotificationAction = "telegram.reaction"
|
||||
NATelegramText NotificationAction = "telegram.text"
|
||||
NATelegramUpdate NotificationAction = "telegram.update"
|
||||
NAPaymentGatewayIntent NotificationAction = "intent.request"
|
||||
NAPaymentGatewayExecution NotificationAction = "execution.result"
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) {
|
||||
nm.NAPasswordReset,
|
||||
nm.NAConfirmationRequest,
|
||||
nm.NATelegramReaction,
|
||||
nm.NATelegramText,
|
||||
nm.NATelegramUpdate,
|
||||
nm.NAPaymentGatewayIntent,
|
||||
nm.NAPaymentGatewayExecution,
|
||||
nm.NADiscoveryServiceAnnounce,
|
||||
|
||||
@@ -16,3 +16,15 @@ type TelegramReactionRequest struct {
|
||||
MessageID string `bson:"messageId,omitempty" json:"message_id,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"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||
|
||||
// AccountCreatedEvent is published when a new user account is registered.
|
||||
message AccountCreatedEvent {
|
||||
// account_ref is the unique reference of the newly created account.
|
||||
string account_ref = 1;
|
||||
// verification_token is the one-time token used to verify the email address.
|
||||
string verification_token = 2;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ message RequestMeta {
|
||||
common.trace.v1.TraceContext trace = 2;
|
||||
}
|
||||
|
||||
// ResponseMeta carries tracing context for fee engine responses.
|
||||
message ResponseMeta {
|
||||
common.trace.v1.TraceContext trace = 1;
|
||||
}
|
||||
@@ -101,6 +102,7 @@ message QuoteFeesRequest {
|
||||
PolicyOverrides policy = 3;
|
||||
}
|
||||
|
||||
// QuoteFeesResponse returns derived fee lines and the rules that produced them.
|
||||
message QuoteFeesResponse {
|
||||
ResponseMeta meta = 1;
|
||||
repeated DerivedPostingLine lines = 2; // derived fee/tax/spread lines
|
||||
@@ -117,6 +119,7 @@ message PrecomputeFeesRequest {
|
||||
int64 ttl_ms = 3; // token validity window
|
||||
}
|
||||
|
||||
// PrecomputeFeesResponse returns a signed fee token and optional preview lines.
|
||||
message PrecomputeFeesResponse {
|
||||
ResponseMeta meta = 1;
|
||||
string fee_quote_token = 2; // opaque, signed
|
||||
@@ -135,6 +138,7 @@ message ValidateFeeTokenRequest {
|
||||
string fee_quote_token = 2;
|
||||
}
|
||||
|
||||
// ValidateFeeTokenResponse returns the validation result and embedded fee data.
|
||||
message ValidateFeeTokenResponse {
|
||||
ResponseMeta meta = 1;
|
||||
bool valid = 2;
|
||||
|
||||
@@ -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/payments/endpoint/v1/endpoint.proto";
|
||||
|
||||
|
||||
// Operation enumerates gateway-level operations that can be performed on a
|
||||
// payment method.
|
||||
enum Operation {
|
||||
OPERATION_UNSPECIFIED = 0;
|
||||
OPERATION_AUTHORIZE = 1;
|
||||
@@ -126,6 +127,7 @@ message RailCapabilities {
|
||||
bool can_release = 7;
|
||||
}
|
||||
|
||||
// LimitsOverride provides per-currency overrides for global limit settings.
|
||||
message LimitsOverride {
|
||||
string max_volume = 1;
|
||||
string min_amount = 2;
|
||||
@@ -166,6 +168,7 @@ enum OperationResult {
|
||||
OPERATION_RESULT_CANCELLED = 3;
|
||||
}
|
||||
|
||||
// OperationError describes a failure returned by a gateway operation.
|
||||
message OperationError {
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
@@ -173,6 +176,8 @@ message OperationError {
|
||||
bool should_rollback = 4;
|
||||
}
|
||||
|
||||
// OperationExecutionStatus reports the result of executing a single gateway
|
||||
// operation, including the settled amount and any error.
|
||||
message OperationExecutionStatus {
|
||||
string idempotency_key = 1;
|
||||
string operation_ref = 2;
|
||||
|
||||
@@ -4,10 +4,7 @@ package common.payment.v1;
|
||||
|
||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||
|
||||
|
||||
// -------------------------
|
||||
// Card network (payment system)
|
||||
// -------------------------
|
||||
// CardNetwork identifies a card payment network (scheme).
|
||||
enum CardNetwork {
|
||||
CARD_NETWORK_UNSPECIFIED = 0;
|
||||
CARD_NETWORK_VISA = 1;
|
||||
@@ -19,6 +16,7 @@ enum CardNetwork {
|
||||
CARD_NETWORK_DISCOVER = 7;
|
||||
}
|
||||
|
||||
// CardFundingType classifies the funding source behind a card.
|
||||
enum CardFundingType {
|
||||
CARD_FUNDING_UNSPECIFIED = 0;
|
||||
CARD_FUNDING_DEBIT = 1;
|
||||
@@ -26,10 +24,8 @@ enum CardFundingType {
|
||||
CARD_FUNDING_PREPAID = 3;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// PCI scope: raw card details
|
||||
// -------------------------
|
||||
|
||||
// RawCardData carries PCI-scope card credentials for tokenisation or
|
||||
// direct processing.
|
||||
message RawCardData {
|
||||
string pan = 1;
|
||||
uint32 exp_month = 2; // 1–12
|
||||
@@ -37,10 +33,8 @@ message RawCardData {
|
||||
string cvv = 4; // optional; often omitted for payouts
|
||||
}
|
||||
|
||||
|
||||
// -------------------------
|
||||
// Safe metadata (display / routing hints)
|
||||
// -------------------------
|
||||
// CardMetadata holds non-sensitive display and routing hints derived from
|
||||
// card details.
|
||||
message CardMetadata {
|
||||
string masked_pan = 1; // e.g. 411111******1111
|
||||
CardNetwork network = 2; // Visa/Mastercard/Mir/...
|
||||
@@ -49,11 +43,8 @@ message CardMetadata {
|
||||
string issuer_name = 5; // display only (if known)
|
||||
}
|
||||
|
||||
|
||||
// -------------------------
|
||||
// Card details
|
||||
// Either inline credentials OR reference to stored payment method
|
||||
// -------------------------
|
||||
// CardDetails provides card credentials for a payment operation, either
|
||||
// as inline raw data or a reference to a stored payment method.
|
||||
message CardDetails {
|
||||
string id = 1;
|
||||
|
||||
@@ -67,5 +58,3 @@ message CardDetails {
|
||||
|
||||
string billing_country = 6; // ISO 3166-1 alpha-2, if you need it per operation
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ package common.payment.v1;
|
||||
|
||||
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 {
|
||||
// id is the unique identifier for this payment method instance.
|
||||
string id = 1;
|
||||
// payment_method_json is the raw JSON payload understood by the target gateway.
|
||||
bytes payment_method_json = 2;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
// ExternalChainDetails describes an external blockchain address as a
|
||||
// payment endpoint.
|
||||
message ExternalChainDetails {
|
||||
// id is the unique identifier for this endpoint instance.
|
||||
string id = 1;
|
||||
// asset identifies the on-chain token (network + symbol + contract).
|
||||
chain.gateway.v1.Asset asset = 2;
|
||||
// address is the destination blockchain address.
|
||||
string address = 3;
|
||||
// memo is an optional transfer memo or tag required by some chains.
|
||||
string memo = 4;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ package common.payment.v1;
|
||||
|
||||
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 {
|
||||
// id is the unique identifier for this endpoint instance.
|
||||
string id = 1;
|
||||
oneof source {
|
||||
// ledger_account_ref is the direct ledger account reference.
|
||||
string ledger_account_ref = 2;
|
||||
// account_code is a human-readable account code resolved at runtime.
|
||||
string account_code = 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ package common.payment.v1;
|
||||
|
||||
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 {
|
||||
// id is the unique identifier for this endpoint instance.
|
||||
string id = 1;
|
||||
// managed_wallet_ref is the reference to the managed wallet record.
|
||||
string managed_wallet_ref = 2;
|
||||
}
|
||||
@@ -4,13 +4,15 @@ package common.payment.v1;
|
||||
|
||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||
|
||||
// -------------------------
|
||||
// Russian bank account details
|
||||
// -------------------------
|
||||
|
||||
// RussianBankDetails carries Russian domestic bank account information for
|
||||
// RUB payouts.
|
||||
message RussianBankDetails {
|
||||
// id is the unique identifier for this endpoint instance.
|
||||
string id = 1;
|
||||
string account_number = 2; // 20 digits
|
||||
string bik = 3; // 9 digits
|
||||
// account_number is the 20-digit Russian bank account number.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ package common.payment.v1;
|
||||
|
||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||
|
||||
// -------------------------
|
||||
// SEPA bank account details
|
||||
// -------------------------
|
||||
|
||||
// SepaBankDetails carries SEPA bank account information for EUR transfers.
|
||||
message SepaBankDetails {
|
||||
// id is the unique identifier for this endpoint instance.
|
||||
string id = 1;
|
||||
string iban = 2; // IBAN
|
||||
string bic = 3; // optional (BIC/SWIFT)
|
||||
// iban is the International Bank Account Number.
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/storable/v1;storab
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
|
||||
// Storable carries common persistence metadata (ID and timestamps).
|
||||
message Storable {
|
||||
string id = 1;
|
||||
google.protobuf.Timestamp created_at = 10;
|
||||
|
||||
@@ -4,19 +4,30 @@ import "google/protobuf/timestamp.proto";
|
||||
|
||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||
|
||||
// NotificationEvent identifies the type and action of an event for routing.
|
||||
message NotificationEvent {
|
||||
string type = 1; // NotificationType
|
||||
string action = 2; // NotificationAction
|
||||
// type is the notification category (e.g. "payment", "account").
|
||||
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 {
|
||||
// sender identifies the originating service.
|
||||
string sender = 1;
|
||||
// message_id is the unique identifier of this event message.
|
||||
string message_id = 2;
|
||||
// timestamp is the time the event was published.
|
||||
google.protobuf.Timestamp timestamp = 3;
|
||||
}
|
||||
|
||||
// Envelope wraps a serialised event payload with routing and metadata.
|
||||
message Envelope {
|
||||
NotificationEvent event = 2; // Notification event with type and action
|
||||
bytes message_data = 3; // Serialized Protobuf message data
|
||||
EventMetadata metadata = 4; // Metadata about the event
|
||||
// event describes the notification type and action for routing.
|
||||
NotificationEvent event = 2;
|
||||
// message_data is the serialised protobuf payload.
|
||||
bytes message_data = 3;
|
||||
// metadata carries provenance information about the event.
|
||||
EventMetadata metadata = 4;
|
||||
}
|
||||
|
||||
@@ -12,29 +12,47 @@ import "api/proto/common/describable/v1/describable.proto";
|
||||
|
||||
// Supported blockchain networks for the managed wallets.
|
||||
enum ChainNetwork {
|
||||
// CHAIN_NETWORK_UNSPECIFIED is the default zero value.
|
||||
CHAIN_NETWORK_UNSPECIFIED = 0;
|
||||
// CHAIN_NETWORK_ETHEREUM_MAINNET is Ethereum layer-1 mainnet.
|
||||
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
|
||||
// CHAIN_NETWORK_ARBITRUM_ONE is the Arbitrum One rollup.
|
||||
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
||||
// CHAIN_NETWORK_TRON_MAINNET is the TRON mainnet.
|
||||
CHAIN_NETWORK_TRON_MAINNET = 4;
|
||||
// CHAIN_NETWORK_TRON_NILE is the TRON Nile testnet.
|
||||
CHAIN_NETWORK_TRON_NILE = 5;
|
||||
// CHAIN_NETWORK_ARBITRUM_SEPOLIA is the Arbitrum Sepolia testnet.
|
||||
CHAIN_NETWORK_ARBITRUM_SEPOLIA = 6;
|
||||
}
|
||||
|
||||
// ManagedWalletStatus represents the lifecycle state of a managed wallet.
|
||||
enum ManagedWalletStatus {
|
||||
// MANAGED_WALLET_STATUS_UNSPECIFIED is the default zero value.
|
||||
MANAGED_WALLET_STATUS_UNSPECIFIED = 0;
|
||||
// MANAGED_WALLET_ACTIVE means the wallet is open and operational.
|
||||
MANAGED_WALLET_ACTIVE = 1;
|
||||
// MANAGED_WALLET_SUSPENDED means the wallet is temporarily disabled.
|
||||
MANAGED_WALLET_SUSPENDED = 2;
|
||||
// MANAGED_WALLET_CLOSED means the wallet is permanently closed.
|
||||
MANAGED_WALLET_CLOSED = 3;
|
||||
}
|
||||
|
||||
// DepositStatus tracks the confirmation state of an inbound deposit.
|
||||
enum DepositStatus {
|
||||
// DEPOSIT_STATUS_UNSPECIFIED is the default zero value.
|
||||
DEPOSIT_STATUS_UNSPECIFIED = 0;
|
||||
// DEPOSIT_PENDING means the deposit has been observed but not yet confirmed.
|
||||
DEPOSIT_PENDING = 1;
|
||||
// DEPOSIT_CONFIRMED means the deposit has been confirmed on-chain.
|
||||
DEPOSIT_CONFIRMED = 2;
|
||||
// DEPOSIT_FAILED means the deposit could not be confirmed.
|
||||
DEPOSIT_FAILED = 3;
|
||||
}
|
||||
|
||||
// TransferStatus tracks the lifecycle of an outbound transfer.
|
||||
enum TransferStatus {
|
||||
// TRANSFER_STATUS_UNSPECIFIED is the default zero value.
|
||||
TRANSFER_STATUS_UNSPECIFIED = 0;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ManagedWallet represents a platform-managed blockchain wallet.
|
||||
message ManagedWallet {
|
||||
string wallet_ref = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -67,6 +86,7 @@ message ManagedWallet {
|
||||
common.describable.v1.Describable describable = 10;
|
||||
}
|
||||
|
||||
// CreateManagedWalletRequest is the request to create a new managed wallet.
|
||||
message CreateManagedWalletRequest {
|
||||
string idempotency_key = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -76,18 +96,22 @@ message CreateManagedWalletRequest {
|
||||
common.describable.v1.Describable describable = 6;
|
||||
}
|
||||
|
||||
// CreateManagedWalletResponse is the response for CreateManagedWallet.
|
||||
message CreateManagedWalletResponse {
|
||||
ManagedWallet wallet = 1;
|
||||
}
|
||||
|
||||
// GetManagedWalletRequest is the request to retrieve a wallet by reference.
|
||||
message GetManagedWalletRequest {
|
||||
string wallet_ref = 1;
|
||||
}
|
||||
|
||||
// GetManagedWalletResponse is the response for GetManagedWallet.
|
||||
message GetManagedWalletResponse {
|
||||
ManagedWallet wallet = 1;
|
||||
}
|
||||
|
||||
// ListManagedWalletsRequest is the request to list wallets with optional filters.
|
||||
message ListManagedWalletsRequest {
|
||||
string organization_ref = 1;
|
||||
reserved 2;
|
||||
@@ -101,11 +125,13 @@ message ListManagedWalletsRequest {
|
||||
google.protobuf.StringValue owner_ref_filter = 5;
|
||||
}
|
||||
|
||||
// ListManagedWalletsResponse is the response for ListManagedWallets.
|
||||
message ListManagedWalletsResponse {
|
||||
repeated ManagedWallet wallets = 1;
|
||||
common.pagination.v1.CursorPageResponse page = 2;
|
||||
}
|
||||
|
||||
// WalletBalance holds the balance breakdown for a managed wallet.
|
||||
message WalletBalance {
|
||||
common.money.v1.Money available = 1;
|
||||
common.money.v1.Money pending_inbound = 2;
|
||||
@@ -114,20 +140,24 @@ message WalletBalance {
|
||||
common.money.v1.Money native_available = 5;
|
||||
}
|
||||
|
||||
// GetWalletBalanceRequest is the request to retrieve a wallet's balance.
|
||||
message GetWalletBalanceRequest {
|
||||
string wallet_ref = 1;
|
||||
}
|
||||
|
||||
// GetWalletBalanceResponse is the response for GetWalletBalance.
|
||||
message GetWalletBalanceResponse {
|
||||
WalletBalance balance = 1;
|
||||
}
|
||||
|
||||
// ServiceFeeBreakdown describes a single fee line item applied to a transfer.
|
||||
message ServiceFeeBreakdown {
|
||||
string fee_code = 1;
|
||||
common.money.v1.Money amount = 2;
|
||||
string description = 3;
|
||||
}
|
||||
|
||||
// TransferDestination identifies where a transfer should be sent.
|
||||
message TransferDestination {
|
||||
oneof destination {
|
||||
string managed_wallet_ref = 1;
|
||||
@@ -136,6 +166,7 @@ message TransferDestination {
|
||||
string memo = 3; // chain-specific memo/tag when required by the destination
|
||||
}
|
||||
|
||||
// Transfer represents an outbound blockchain transfer.
|
||||
message Transfer {
|
||||
string transfer_ref = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -156,6 +187,7 @@ message Transfer {
|
||||
string operation_ref = 17;
|
||||
}
|
||||
|
||||
// SubmitTransferRequest is the request to submit an outbound transfer.
|
||||
message SubmitTransferRequest {
|
||||
string idempotency_key = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -169,18 +201,22 @@ message SubmitTransferRequest {
|
||||
string payment_ref = 10;
|
||||
}
|
||||
|
||||
// SubmitTransferResponse is the response for SubmitTransfer.
|
||||
message SubmitTransferResponse {
|
||||
Transfer transfer = 1;
|
||||
}
|
||||
|
||||
// GetTransferRequest is the request to retrieve a transfer by reference.
|
||||
message GetTransferRequest {
|
||||
string transfer_ref = 1;
|
||||
}
|
||||
|
||||
// GetTransferResponse is the response for GetTransfer.
|
||||
message GetTransferResponse {
|
||||
Transfer transfer = 1;
|
||||
}
|
||||
|
||||
// ListTransfersRequest is the request to list transfers with optional filters.
|
||||
message ListTransfersRequest {
|
||||
string source_wallet_ref = 1;
|
||||
string destination_wallet_ref = 2;
|
||||
@@ -188,11 +224,13 @@ message ListTransfersRequest {
|
||||
common.pagination.v1.CursorPageRequest page = 4;
|
||||
}
|
||||
|
||||
// ListTransfersResponse is the response for ListTransfers.
|
||||
message ListTransfersResponse {
|
||||
repeated Transfer transfers = 1;
|
||||
common.pagination.v1.CursorPageResponse page = 2;
|
||||
}
|
||||
|
||||
// EstimateTransferFeeRequest is the request to estimate network fees for a transfer.
|
||||
message EstimateTransferFeeRequest {
|
||||
string source_wallet_ref = 1;
|
||||
TransferDestination destination = 2;
|
||||
@@ -200,21 +238,25 @@ message EstimateTransferFeeRequest {
|
||||
Asset asset = 4;
|
||||
}
|
||||
|
||||
// EstimateTransferFeeResponse is the response for EstimateTransferFee.
|
||||
message EstimateTransferFeeResponse {
|
||||
common.money.v1.Money network_fee = 1;
|
||||
string estimation_context = 2;
|
||||
}
|
||||
|
||||
// ComputeGasTopUpRequest is the request to calculate the gas top-up needed.
|
||||
message ComputeGasTopUpRequest {
|
||||
string wallet_ref = 1;
|
||||
common.money.v1.Money estimated_total_fee = 2;
|
||||
}
|
||||
|
||||
// ComputeGasTopUpResponse is the response for ComputeGasTopUp.
|
||||
message ComputeGasTopUpResponse {
|
||||
common.money.v1.Money topup_amount = 1;
|
||||
bool cap_hit = 2;
|
||||
}
|
||||
|
||||
// EnsureGasTopUpRequest is the request to top up gas for a wallet if needed.
|
||||
message EnsureGasTopUpRequest {
|
||||
string idempotency_key = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -227,12 +269,14 @@ message EnsureGasTopUpRequest {
|
||||
string operation_ref = 9;
|
||||
}
|
||||
|
||||
// EnsureGasTopUpResponse is the response for EnsureGasTopUp.
|
||||
message EnsureGasTopUpResponse {
|
||||
common.money.v1.Money topup_amount = 1;
|
||||
bool cap_hit = 2;
|
||||
Transfer transfer = 3;
|
||||
}
|
||||
|
||||
// WalletDepositObservedEvent is emitted when a deposit is detected on-chain.
|
||||
message WalletDepositObservedEvent {
|
||||
string deposit_ref = 1;
|
||||
string wallet_ref = 2;
|
||||
@@ -245,6 +289,7 @@ message WalletDepositObservedEvent {
|
||||
google.protobuf.Timestamp observed_at = 9;
|
||||
}
|
||||
|
||||
// TransferStatusChangedEvent is emitted when a transfer changes status.
|
||||
message TransferStatusChangedEvent {
|
||||
Transfer transfer = 1;
|
||||
string reason = 2;
|
||||
|
||||
@@ -72,10 +72,12 @@ message CardPayoutResponse {
|
||||
string error_message = 5;
|
||||
}
|
||||
|
||||
// GetCardPayoutStatusRequest fetches the current status of a payout.
|
||||
message GetCardPayoutStatusRequest {
|
||||
string payout_id = 1;
|
||||
}
|
||||
|
||||
// GetCardPayoutStatusResponse returns the current payout state.
|
||||
message GetCardPayoutStatusResponse {
|
||||
CardPayoutState payout = 1;
|
||||
}
|
||||
@@ -85,8 +87,10 @@ message CardPayoutStatusChangedEvent {
|
||||
CardPayoutState payout = 1;
|
||||
}
|
||||
|
||||
// ListGatewayInstancesRequest requests all registered gateway instances.
|
||||
message ListGatewayInstancesRequest {}
|
||||
|
||||
// ListGatewayInstancesResponse returns the available gateway instances.
|
||||
message ListGatewayInstancesResponse {
|
||||
repeated common.gateway.v1.GatewayInstanceDescriptor items = 1;
|
||||
}
|
||||
|
||||
@@ -11,51 +11,89 @@ import "api/proto/common/money/v1/money.proto";
|
||||
|
||||
// ===== Enums =====
|
||||
|
||||
// EntryType classifies the kind of journal entry.
|
||||
enum EntryType {
|
||||
// ENTRY_TYPE_UNSPECIFIED is the default zero value.
|
||||
ENTRY_TYPE_UNSPECIFIED = 0;
|
||||
// ENTRY_CREDIT records an inbound credit.
|
||||
ENTRY_CREDIT = 1;
|
||||
// ENTRY_DEBIT records an outbound debit.
|
||||
ENTRY_DEBIT = 2;
|
||||
// ENTRY_TRANSFER records a transfer between accounts.
|
||||
ENTRY_TRANSFER = 3;
|
||||
// ENTRY_FX records a foreign-exchange conversion.
|
||||
ENTRY_FX = 4;
|
||||
// ENTRY_FEE records a fee charge.
|
||||
ENTRY_FEE = 5;
|
||||
// ENTRY_ADJUST records a manual adjustment.
|
||||
ENTRY_ADJUST = 6;
|
||||
// ENTRY_REVERSE records a reversal of a prior entry.
|
||||
ENTRY_REVERSE = 7;
|
||||
}
|
||||
|
||||
// LineType classifies the purpose of a posting line within an entry.
|
||||
enum LineType {
|
||||
// LINE_TYPE_UNSPECIFIED is the default zero value.
|
||||
LINE_TYPE_UNSPECIFIED = 0;
|
||||
// LINE_MAIN is the primary posting line.
|
||||
LINE_MAIN = 1;
|
||||
// LINE_FEE is a fee posting line.
|
||||
LINE_FEE = 2;
|
||||
// LINE_SPREAD is an FX spread posting line.
|
||||
LINE_SPREAD = 3;
|
||||
// LINE_REVERSAL is a reversal posting line.
|
||||
LINE_REVERSAL = 4;
|
||||
}
|
||||
|
||||
// AccountType classifies the fundamental accounting type of an account.
|
||||
enum AccountType {
|
||||
// ACCOUNT_TYPE_UNSPECIFIED is the default zero value.
|
||||
ACCOUNT_TYPE_UNSPECIFIED = 0;
|
||||
// ACCOUNT_TYPE_ASSET represents an asset account.
|
||||
ACCOUNT_TYPE_ASSET = 1;
|
||||
// ACCOUNT_TYPE_LIABILITY represents a liability account.
|
||||
ACCOUNT_TYPE_LIABILITY = 2;
|
||||
// ACCOUNT_TYPE_REVENUE represents a revenue account.
|
||||
ACCOUNT_TYPE_REVENUE = 3;
|
||||
// ACCOUNT_TYPE_EXPENSE represents an expense account.
|
||||
ACCOUNT_TYPE_EXPENSE = 4;
|
||||
}
|
||||
|
||||
// AccountStatus indicates whether an account is active or frozen.
|
||||
enum AccountStatus {
|
||||
// ACCOUNT_STATUS_UNSPECIFIED is the default zero value.
|
||||
ACCOUNT_STATUS_UNSPECIFIED = 0;
|
||||
// ACCOUNT_STATUS_ACTIVE means the account accepts postings.
|
||||
ACCOUNT_STATUS_ACTIVE = 1;
|
||||
// ACCOUNT_STATUS_FROZEN means the account is blocked from new postings.
|
||||
ACCOUNT_STATUS_FROZEN = 2;
|
||||
}
|
||||
|
||||
// AccountRole defines the functional role of an account within an organization.
|
||||
enum AccountRole {
|
||||
// ACCOUNT_ROLE_UNSPECIFIED is the default zero value.
|
||||
ACCOUNT_ROLE_UNSPECIFIED = 0;
|
||||
// ACCOUNT_ROLE_OPERATING is the main operating account.
|
||||
ACCOUNT_ROLE_OPERATING = 1;
|
||||
// ACCOUNT_ROLE_HOLD is a temporary hold account.
|
||||
ACCOUNT_ROLE_HOLD = 2;
|
||||
// ACCOUNT_ROLE_TRANSIT is an in-transit account.
|
||||
ACCOUNT_ROLE_TRANSIT = 3;
|
||||
// ACCOUNT_ROLE_SETTLEMENT is a settlement account.
|
||||
ACCOUNT_ROLE_SETTLEMENT = 4;
|
||||
// ACCOUNT_ROLE_CLEARING is a clearing account.
|
||||
ACCOUNT_ROLE_CLEARING = 5;
|
||||
// ACCOUNT_ROLE_PENDING is a pending-settlement account.
|
||||
ACCOUNT_ROLE_PENDING = 6;
|
||||
// ACCOUNT_ROLE_RESERVE is a reserve account.
|
||||
ACCOUNT_ROLE_RESERVE = 7;
|
||||
// ACCOUNT_ROLE_LIQUIDITY is a liquidity pool account.
|
||||
ACCOUNT_ROLE_LIQUIDITY = 8;
|
||||
// ACCOUNT_ROLE_FEE is a fee collection account.
|
||||
ACCOUNT_ROLE_FEE = 9;
|
||||
// ACCOUNT_ROLE_CHARGEBACK is a chargeback account.
|
||||
ACCOUNT_ROLE_CHARGEBACK = 10;
|
||||
// ACCOUNT_ROLE_ADJUSTMENT is an adjustment account.
|
||||
ACCOUNT_ROLE_ADJUSTMENT = 11;
|
||||
}
|
||||
|
||||
@@ -87,6 +125,7 @@ message PostingLine {
|
||||
|
||||
// ===== Requests/Responses =====
|
||||
|
||||
// CreateAccountRequest is the request to create a new ledger account.
|
||||
message CreateAccountRequest {
|
||||
string organization_ref = 1;
|
||||
string owner_ref = 2;
|
||||
@@ -103,6 +142,7 @@ message CreateAccountRequest {
|
||||
AccountRole role = 11;
|
||||
}
|
||||
|
||||
// CreateAccountResponse is the response for CreateAccount.
|
||||
message CreateAccountResponse {
|
||||
LedgerAccount account = 1;
|
||||
}
|
||||
@@ -121,6 +161,7 @@ message PostCreditRequest {
|
||||
AccountRole role = 10; // optional: assert target account has this role
|
||||
}
|
||||
|
||||
// PostDebitRequest is the request to post a debit entry.
|
||||
message PostDebitRequest {
|
||||
string idempotency_key = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -134,6 +175,7 @@ message PostDebitRequest {
|
||||
AccountRole role = 10; // optional: assert target account has this role
|
||||
}
|
||||
|
||||
// TransferRequest is the request to transfer funds between two ledger accounts.
|
||||
message TransferRequest {
|
||||
string idempotency_key = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -148,6 +190,7 @@ message TransferRequest {
|
||||
AccountRole to_role = 11;
|
||||
}
|
||||
|
||||
// FXRequest is the request to post a foreign-exchange conversion entry.
|
||||
message FXRequest {
|
||||
string idempotency_key = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -164,6 +207,7 @@ message FXRequest {
|
||||
google.protobuf.Timestamp event_time = 11;
|
||||
}
|
||||
|
||||
// PostResponse is the common response returned after any posting operation.
|
||||
message PostResponse {
|
||||
string journal_entry_ref = 1;
|
||||
int64 version = 2; // ledger's entry version (monotonic per scope)
|
||||
@@ -172,10 +216,12 @@ message PostResponse {
|
||||
|
||||
// ---- Balances & Entries ----
|
||||
|
||||
// GetBalanceRequest is the request to retrieve an account balance.
|
||||
message GetBalanceRequest {
|
||||
string ledger_account_ref = 1;
|
||||
}
|
||||
|
||||
// BalanceResponse holds the current balance of a ledger account.
|
||||
message BalanceResponse {
|
||||
string ledger_account_ref = 1;
|
||||
common.money.v1.Money balance = 2;
|
||||
@@ -183,10 +229,12 @@ message BalanceResponse {
|
||||
google.protobuf.Timestamp last_updated = 4;
|
||||
}
|
||||
|
||||
// GetEntryRequest is the request to retrieve a journal entry by reference.
|
||||
message GetEntryRequest {
|
||||
string entry_ref = 1;
|
||||
}
|
||||
|
||||
// JournalEntryResponse represents a complete journal entry with all posting lines.
|
||||
message JournalEntryResponse {
|
||||
string entry_ref = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -199,17 +247,20 @@ message JournalEntryResponse {
|
||||
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 {
|
||||
string ledger_account_ref = 1;
|
||||
string cursor = 2; // opaque
|
||||
int32 limit = 3; // page size
|
||||
}
|
||||
|
||||
// StatementResponse is a paginated list of journal entries.
|
||||
message StatementResponse {
|
||||
repeated JournalEntryResponse entries = 1;
|
||||
string next_cursor = 2;
|
||||
}
|
||||
|
||||
// ListAccountsRequest is the request to list ledger accounts with optional filters.
|
||||
message ListAccountsRequest {
|
||||
string organization_ref = 1;
|
||||
// Optional owner filter with 3-state semantics:
|
||||
@@ -219,28 +270,33 @@ message ListAccountsRequest {
|
||||
google.protobuf.StringValue owner_ref_filter = 2;
|
||||
}
|
||||
|
||||
// ListAccountsResponse is the response for ListAccounts.
|
||||
message ListAccountsResponse {
|
||||
repeated LedgerAccount accounts = 1;
|
||||
}
|
||||
|
||||
// ---- Account status mutations ----
|
||||
|
||||
// BlockAccountRequest is the request to freeze (block) a ledger account.
|
||||
message BlockAccountRequest {
|
||||
string ledger_account_ref = 1;
|
||||
string organization_ref = 2;
|
||||
AccountRole role = 3; // optional: assert account has this role before blocking
|
||||
}
|
||||
|
||||
// BlockAccountResponse is the response for BlockAccount.
|
||||
message BlockAccountResponse {
|
||||
LedgerAccount account = 1;
|
||||
}
|
||||
|
||||
// UnblockAccountRequest is the request to unfreeze (unblock) a ledger account.
|
||||
message UnblockAccountRequest {
|
||||
string ledger_account_ref = 1;
|
||||
string organization_ref = 2;
|
||||
AccountRole role = 3; // optional: assert account has this role before unblocking
|
||||
}
|
||||
|
||||
// UnblockAccountResponse is the response for UnblockAccount.
|
||||
message UnblockAccountResponse {
|
||||
LedgerAccount account = 1;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@ option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||
|
||||
import "operation_result.proto";
|
||||
|
||||
// NotificationSentEvent is published after a notification has been delivered
|
||||
// (or delivery has failed) to a user.
|
||||
message NotificationSentEvent {
|
||||
// user_id identifies the recipient.
|
||||
string user_id = 1;
|
||||
// template_id is the notification template that was rendered.
|
||||
string template_id = 2;
|
||||
// channel is the delivery channel (e.g. "email", "sms", "push").
|
||||
string channel = 3;
|
||||
// locale is the language/region used for rendering (e.g. "en", "ru").
|
||||
string locale = 4;
|
||||
// status reports whether the delivery succeeded.
|
||||
OperationResult status = 5;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ syntax = "proto3";
|
||||
|
||||
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 {
|
||||
// object_ref is the unique reference of the updated object.
|
||||
string object_ref = 1;
|
||||
// actor_account_ref identifies the account that performed the update.
|
||||
string actor_account_ref = 2;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||
|
||||
// OperationResult reports the success or failure of an asynchronous operation.
|
||||
message OperationResult {
|
||||
// is_successful is true when the operation completed without errors.
|
||||
bool is_successful = 1;
|
||||
// error_description contains a human-readable error message when
|
||||
// is_successful is false.
|
||||
string error_description = 2;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import "api/proto/common/money/v1/money.proto";
|
||||
import "api/proto/common/fx/v1/fx.proto";
|
||||
import "api/proto/common/trace/v1/trace.proto";
|
||||
|
||||
|
||||
// RateSnapshot holds a point-in-time rate observation from a provider.
|
||||
message RateSnapshot {
|
||||
common.fx.v1.CurrencyPair pair = 1;
|
||||
common.money.v1.Decimal mid = 2;
|
||||
@@ -21,6 +21,7 @@ message RateSnapshot {
|
||||
common.money.v1.Decimal spread_bps = 8;
|
||||
}
|
||||
|
||||
// RequestMeta carries caller identity and tracing context for oracle requests.
|
||||
message RequestMeta {
|
||||
reserved 1, 4, 5;
|
||||
reserved "request_ref", "idempotency_key", "trace_ref";
|
||||
@@ -30,6 +31,7 @@ message RequestMeta {
|
||||
common.trace.v1.TraceContext trace = 6;
|
||||
}
|
||||
|
||||
// ResponseMeta carries tracing context for oracle responses.
|
||||
message ResponseMeta {
|
||||
reserved 1, 2;
|
||||
reserved "request_ref", "trace_ref";
|
||||
@@ -37,6 +39,7 @@ message ResponseMeta {
|
||||
common.trace.v1.TraceContext trace = 3;
|
||||
}
|
||||
|
||||
// Quote represents a priced FX quote with an expiry window.
|
||||
message Quote {
|
||||
string quote_ref = 1;
|
||||
common.fx.v1.CurrencyPair pair = 2;
|
||||
@@ -51,6 +54,7 @@ message Quote {
|
||||
google.protobuf.Timestamp priced_at = 11;
|
||||
}
|
||||
|
||||
// GetQuoteRequest is the request to obtain an FX quote.
|
||||
message GetQuoteRequest {
|
||||
RequestMeta meta = 1;
|
||||
common.fx.v1.CurrencyPair pair = 2;
|
||||
@@ -65,16 +69,19 @@ message GetQuoteRequest {
|
||||
int32 max_age_ms = 9;
|
||||
}
|
||||
|
||||
// GetQuoteResponse is the response for GetQuote.
|
||||
message GetQuoteResponse {
|
||||
ResponseMeta meta = 1;
|
||||
Quote quote = 2;
|
||||
}
|
||||
|
||||
// ValidateQuoteRequest is the request to check whether a quote is still valid.
|
||||
message ValidateQuoteRequest {
|
||||
RequestMeta meta = 1;
|
||||
string quote_ref = 2;
|
||||
}
|
||||
|
||||
// ValidateQuoteResponse is the response for ValidateQuote.
|
||||
message ValidateQuoteResponse {
|
||||
ResponseMeta meta = 1;
|
||||
Quote quote = 2;
|
||||
@@ -82,48 +89,61 @@ message ValidateQuoteResponse {
|
||||
string reason = 4;
|
||||
}
|
||||
|
||||
// ConsumeQuoteRequest marks a quote as used, linking it to a ledger transaction.
|
||||
message ConsumeQuoteRequest {
|
||||
RequestMeta meta = 1;
|
||||
string quote_ref = 2;
|
||||
string ledger_txn_ref = 3;
|
||||
}
|
||||
|
||||
// ConsumeQuoteResponse is the response for ConsumeQuote.
|
||||
message ConsumeQuoteResponse {
|
||||
ResponseMeta meta = 1;
|
||||
bool consumed = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
// LatestRateRequest is the request to fetch the most recent rate for a pair.
|
||||
message LatestRateRequest {
|
||||
RequestMeta meta = 1;
|
||||
common.fx.v1.CurrencyPair pair = 2;
|
||||
string provider = 3;
|
||||
}
|
||||
|
||||
// LatestRateResponse is the response for LatestRate.
|
||||
message LatestRateResponse {
|
||||
ResponseMeta meta = 1;
|
||||
RateSnapshot rate = 2;
|
||||
}
|
||||
|
||||
// ListPairsRequest is the request to list all supported currency pairs.
|
||||
message ListPairsRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
// PairMeta holds metadata for a supported currency pair.
|
||||
message PairMeta {
|
||||
common.fx.v1.CurrencyPair pair = 1;
|
||||
common.money.v1.CurrencyMeta base_meta = 2;
|
||||
common.money.v1.CurrencyMeta quote_meta = 3;
|
||||
}
|
||||
|
||||
// ListPairsResponse is the response for ListPairs.
|
||||
message ListPairsResponse {
|
||||
ResponseMeta meta = 1;
|
||||
repeated PairMeta pairs = 2;
|
||||
}
|
||||
|
||||
// Oracle provides FX rate quoting, validation, and consumption.
|
||||
service Oracle {
|
||||
// GetQuote returns a priced FX quote for a currency pair.
|
||||
rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse);
|
||||
// ValidateQuote checks whether an existing quote is still valid.
|
||||
rpc ValidateQuote(ValidateQuoteRequest) returns (ValidateQuoteResponse);
|
||||
// ConsumeQuote marks a quote as consumed and links it to a ledger transaction.
|
||||
rpc ConsumeQuote(ConsumeQuoteRequest) returns (ConsumeQuoteResponse);
|
||||
// LatestRate returns the most recent rate snapshot for a currency pair.
|
||||
rpc LatestRate(LatestRateRequest) returns (LatestRateResponse);
|
||||
// ListPairs returns all supported currency pairs.
|
||||
rpc ListPairs(ListPairsRequest) returns (ListPairsResponse);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ syntax = "proto3";
|
||||
|
||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||
|
||||
// PasswordResetEvent is published when a user requests a password reset.
|
||||
message PasswordResetEvent {
|
||||
// account_ref is the unique reference of the account requesting the reset.
|
||||
string account_ref = 1;
|
||||
// reset_token is the one-time token the user must present to set a new
|
||||
// password.
|
||||
string reset_token = 2;
|
||||
}
|
||||
@@ -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/permission_bound/v1/pbound.proto";
|
||||
|
||||
// PaymentMethodType classifies the kind of payment instrument.
|
||||
enum PaymentMethodType {
|
||||
PAYMENT_METHOD_TYPE_UNSPECIFIED = 0;
|
||||
PAYMENT_METHOD_TYPE_IBAN = 1;
|
||||
@@ -19,6 +20,7 @@ enum PaymentMethodType {
|
||||
PAYMENT_METHOD_TYPE_ACCOUNT = 8;
|
||||
}
|
||||
|
||||
// PaymentMethod represents a stored payment instrument (card, IBAN, wallet, etc.).
|
||||
message PaymentMethod {
|
||||
common.describable.v1.Describable describable = 1;
|
||||
string recipient_ref = 2;
|
||||
@@ -27,6 +29,8 @@ message PaymentMethod {
|
||||
bool is_main = 5;
|
||||
}
|
||||
|
||||
// PaymentEndpoint resolves a payment destination by reference, inline method,
|
||||
// or payee lookup.
|
||||
message PaymentEndpoint {
|
||||
oneof source {
|
||||
string payment_method_ref = 1;
|
||||
@@ -35,6 +39,8 @@ message PaymentEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
// PaymentMethodRecord wraps a PaymentMethod with its permission and
|
||||
// persistence metadata.
|
||||
message PaymentMethodRecord {
|
||||
common.pbound.v1.PermissionBound permission_bound = 1;
|
||||
PaymentMethod payment_method = 2;
|
||||
|
||||
@@ -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/payments/endpoint/v1/endpoint.proto";
|
||||
|
||||
// CreatePaymentMethodRequest is the request to create a new payment method.
|
||||
message CreatePaymentMethodRequest {
|
||||
string account_ref = 1;
|
||||
string organization_ref = 2;
|
||||
payments.endpoint.v1.PaymentMethod payment_method = 3;
|
||||
}
|
||||
|
||||
// CreatePaymentMethodResponse is the response for CreatePaymentMethod.
|
||||
message CreatePaymentMethodResponse {
|
||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||
}
|
||||
|
||||
// GetPaymentMethodRequest is the request to retrieve a payment method.
|
||||
message GetPaymentMethodRequest {
|
||||
string account_ref = 1;
|
||||
string payment_method_ref = 2;
|
||||
}
|
||||
|
||||
// GetPaymentMethodResponse is the response for GetPaymentMethod.
|
||||
message GetPaymentMethodResponse {
|
||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||
}
|
||||
|
||||
// GetPaymentMethodPrivateRequest retrieves a payment method without permission checks.
|
||||
message GetPaymentMethodPrivateRequest {
|
||||
string organization_ref = 1;
|
||||
oneof selector {
|
||||
@@ -35,33 +40,43 @@ message GetPaymentMethodPrivateRequest {
|
||||
PrivateEndpoint endpoint = 4;
|
||||
}
|
||||
|
||||
// PrivateEndpoint specifies which side of a payment method to retrieve.
|
||||
enum PrivateEndpoint {
|
||||
// PRIVATE_ENDPOINT_UNSPECIFIED is the default zero value.
|
||||
PRIVATE_ENDPOINT_UNSPECIFIED = 0;
|
||||
// PRIVATE_ENDPOINT_SOURCE retrieves the source endpoint.
|
||||
PRIVATE_ENDPOINT_SOURCE = 1;
|
||||
// PRIVATE_ENDPOINT_DESTINATION retrieves the destination endpoint.
|
||||
PRIVATE_ENDPOINT_DESTINATION = 2;
|
||||
}
|
||||
|
||||
// GetPaymentMethodPrivateResponse is the response for GetPaymentMethodPrivate.
|
||||
message GetPaymentMethodPrivateResponse {
|
||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||
}
|
||||
|
||||
// UpdatePaymentMethodRequest is the request to update an existing payment method.
|
||||
message UpdatePaymentMethodRequest {
|
||||
string account_ref = 1;
|
||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 2;
|
||||
}
|
||||
|
||||
// UpdatePaymentMethodResponse is the response for UpdatePaymentMethod.
|
||||
message UpdatePaymentMethodResponse {
|
||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||
}
|
||||
|
||||
// DeletePaymentMethodRequest is the request to delete a payment method.
|
||||
message DeletePaymentMethodRequest {
|
||||
string account_ref = 1;
|
||||
string payment_method_ref = 2;
|
||||
bool cascade = 3;
|
||||
}
|
||||
|
||||
// DeletePaymentMethodResponse is the response for DeletePaymentMethod.
|
||||
message DeletePaymentMethodResponse {}
|
||||
|
||||
// SetPaymentMethodArchivedRequest is the request to archive or unarchive a payment method.
|
||||
message SetPaymentMethodArchivedRequest {
|
||||
string account_ref = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -70,8 +85,10 @@ message SetPaymentMethodArchivedRequest {
|
||||
bool cascade = 5;
|
||||
}
|
||||
|
||||
// SetPaymentMethodArchivedResponse is the response for SetPaymentMethodArchived.
|
||||
message SetPaymentMethodArchivedResponse {}
|
||||
|
||||
// ListPaymentMethodsRequest is the request to list payment methods with optional filters.
|
||||
message ListPaymentMethodsRequest {
|
||||
string account_ref = 1;
|
||||
string organization_ref = 2;
|
||||
@@ -79,6 +96,7 @@ message ListPaymentMethodsRequest {
|
||||
common.pagination.v2.ViewCursor cursor = 4;
|
||||
}
|
||||
|
||||
// ListPaymentMethodsResponse is the response for ListPaymentMethods.
|
||||
message ListPaymentMethodsResponse {
|
||||
repeated payments.endpoint.v1.PaymentMethodRecord payment_methods = 1;
|
||||
}
|
||||
@@ -91,7 +109,7 @@ service PaymentMethodsService {
|
||||
rpc GetPaymentMethod(GetPaymentMethodRequest) returns (GetPaymentMethodResponse);
|
||||
// UpdatePaymentMethod updates an existing payment method.
|
||||
rpc UpdatePaymentMethod(UpdatePaymentMethodRequest) returns (UpdatePaymentMethodResponse);
|
||||
// Delete exising payment method
|
||||
// DeletePaymentMethod deletes an existing payment method.
|
||||
rpc DeletePaymentMethod(DeletePaymentMethodRequest) returns (DeletePaymentMethodResponse);
|
||||
// SetPaymentMethodArchived sets the archived status of a payment method.
|
||||
rpc SetPaymentMethodArchived(SetPaymentMethodArchivedRequest) returns (SetPaymentMethodArchivedResponse);
|
||||
|
||||
@@ -10,6 +10,8 @@ import "api/proto/gateway/chain/v1/chain.proto";
|
||||
import "api/proto/gateway/mntx/v1/mntx.proto";
|
||||
import "api/proto/payments/shared/v1/shared.proto";
|
||||
|
||||
// InitiatePaymentsRequest triggers execution of all payment intents within
|
||||
// a previously accepted quote.
|
||||
message InitiatePaymentsRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -17,10 +19,12 @@ message InitiatePaymentsRequest {
|
||||
map<string, string> metadata = 4;
|
||||
}
|
||||
|
||||
// InitiatePaymentsResponse returns the created payments.
|
||||
message InitiatePaymentsResponse {
|
||||
repeated payments.shared.v1.Payment payments = 1;
|
||||
}
|
||||
|
||||
// InitiatePaymentRequest creates a single payment from a standalone intent.
|
||||
message InitiatePaymentRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -29,19 +33,23 @@ message InitiatePaymentRequest {
|
||||
string quote_ref = 5;
|
||||
}
|
||||
|
||||
// InitiatePaymentResponse returns the created payment.
|
||||
message InitiatePaymentResponse {
|
||||
payments.shared.v1.Payment payment = 1;
|
||||
}
|
||||
|
||||
// GetPaymentRequest fetches a payment by its reference.
|
||||
message GetPaymentRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string payment_ref = 2;
|
||||
}
|
||||
|
||||
// GetPaymentResponse returns the requested payment.
|
||||
message GetPaymentResponse {
|
||||
payments.shared.v1.Payment payment = 1;
|
||||
}
|
||||
|
||||
// ListPaymentsRequest queries payments with optional state and endpoint filters.
|
||||
message ListPaymentsRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
repeated payments.shared.v1.PaymentState filter_states = 2;
|
||||
@@ -51,48 +59,63 @@ message ListPaymentsRequest {
|
||||
string organization_ref = 6;
|
||||
}
|
||||
|
||||
// ListPaymentsResponse returns a page of matching payments.
|
||||
message ListPaymentsResponse {
|
||||
repeated payments.shared.v1.Payment payments = 1;
|
||||
common.pagination.v1.CursorPageResponse page = 2;
|
||||
}
|
||||
|
||||
// CancelPaymentRequest requests cancellation of a payment that has not yet
|
||||
// been settled.
|
||||
message CancelPaymentRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string payment_ref = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
// CancelPaymentResponse returns the updated payment after cancellation.
|
||||
message CancelPaymentResponse {
|
||||
payments.shared.v1.Payment payment = 1;
|
||||
}
|
||||
|
||||
// ProcessTransferUpdateRequest handles a blockchain transfer status change
|
||||
// event from the chain gateway.
|
||||
message ProcessTransferUpdateRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
chain.gateway.v1.TransferStatusChangedEvent event = 2;
|
||||
}
|
||||
|
||||
// ProcessTransferUpdateResponse returns the payment after processing.
|
||||
message ProcessTransferUpdateResponse {
|
||||
payments.shared.v1.Payment payment = 1;
|
||||
}
|
||||
|
||||
// ProcessDepositObservedRequest handles a wallet deposit observation event
|
||||
// from the chain gateway.
|
||||
message ProcessDepositObservedRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
chain.gateway.v1.WalletDepositObservedEvent event = 2;
|
||||
}
|
||||
|
||||
// ProcessDepositObservedResponse returns the payment after processing.
|
||||
message ProcessDepositObservedResponse {
|
||||
payments.shared.v1.Payment payment = 1;
|
||||
}
|
||||
|
||||
// ProcessCardPayoutUpdateRequest handles a card payout status change event
|
||||
// from the card gateway.
|
||||
message ProcessCardPayoutUpdateRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2;
|
||||
}
|
||||
|
||||
// ProcessCardPayoutUpdateResponse returns the payment after processing.
|
||||
message ProcessCardPayoutUpdateResponse {
|
||||
payments.shared.v1.Payment payment = 1;
|
||||
}
|
||||
|
||||
// InitiateConversionRequest creates an FX conversion payment between two
|
||||
// ledger endpoints.
|
||||
message InitiateConversionRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -103,18 +126,30 @@ message InitiateConversionRequest {
|
||||
map<string, string> metadata = 7;
|
||||
}
|
||||
|
||||
// InitiateConversionResponse returns the created conversion payment.
|
||||
message InitiateConversionResponse {
|
||||
payments.shared.v1.Payment conversion = 1;
|
||||
}
|
||||
|
||||
// PaymentExecutionService orchestrates payment lifecycle operations across
|
||||
// ledger, blockchain, card, and FX rails.
|
||||
service PaymentExecutionService {
|
||||
// InitiatePayments executes all intents within a quote.
|
||||
rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse);
|
||||
// InitiatePayment creates and executes a single payment.
|
||||
rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse);
|
||||
// CancelPayment cancels a pending payment.
|
||||
rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse);
|
||||
// GetPayment retrieves a payment by reference.
|
||||
rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse);
|
||||
// ListPayments queries payments with filters and pagination.
|
||||
rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse);
|
||||
// InitiateConversion creates an FX conversion payment.
|
||||
rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse);
|
||||
// ProcessTransferUpdate handles blockchain transfer status callbacks.
|
||||
rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse);
|
||||
// ProcessDepositObserved handles deposit observation callbacks.
|
||||
rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse);
|
||||
// ProcessCardPayoutUpdate handles card payout status callbacks.
|
||||
rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse);
|
||||
}
|
||||
|
||||
193
api/proto/payments/orchestration/v2/orchestration.proto
Normal file
193
api/proto/payments/orchestration/v2/orchestration.proto
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
// -------------------------
|
||||
// External payment semantics
|
||||
// -------------------------
|
||||
// PaymentIntent describes the full intent for an external payment,
|
||||
// wrapping a transfer with payer/payee identity and purpose.
|
||||
message PaymentIntent {
|
||||
// transfer is the underlying value movement.
|
||||
payments.transfer.v1.TransferIntent transfer = 1;
|
||||
|
||||
// payer_ref identifies the entity funding the payment.
|
||||
string payer_ref = 2;
|
||||
// payee_ref identifies the payment beneficiary.
|
||||
string payee_ref = 3;
|
||||
|
||||
// purpose is a human-readable description of the payment reason.
|
||||
string purpose = 4;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
// QuotePaymentRequest is the request to quote a single payment.
|
||||
message QuotePaymentRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -14,6 +14,7 @@ message QuotePaymentRequest {
|
||||
bool preview_only = 4;
|
||||
}
|
||||
|
||||
// QuotePaymentResponse is the response for QuotePayment.
|
||||
message QuotePaymentResponse {
|
||||
payments.shared.v1.PaymentQuote quote = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -21,6 +22,7 @@ message QuotePaymentResponse {
|
||||
string execution_note = 3;
|
||||
}
|
||||
|
||||
// QuotePaymentsRequest is the request to quote multiple payments in a batch.
|
||||
message QuotePaymentsRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -28,6 +30,7 @@ message QuotePaymentsRequest {
|
||||
bool preview_only = 4;
|
||||
}
|
||||
|
||||
// QuotePaymentsResponse is the response for QuotePayments.
|
||||
message QuotePaymentsResponse {
|
||||
string quote_ref = 1;
|
||||
payments.shared.v1.PaymentQuoteAggregate aggregate = 2;
|
||||
@@ -35,6 +38,7 @@ message QuotePaymentsResponse {
|
||||
string idempotency_key = 4;
|
||||
}
|
||||
|
||||
// QuotationService provides payment quoting capabilities.
|
||||
service QuotationService {
|
||||
// QuotePayment returns a quote for a single payment request.
|
||||
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
|
||||
|
||||
@@ -12,6 +12,7 @@ import "api/proto/common/payment/v1/settlement.proto";
|
||||
import "api/proto/billing/fees/v1/fees.proto";
|
||||
import "api/proto/oracle/v1/oracle.proto";
|
||||
|
||||
// QuoteState tracks the lifecycle of a payment quote.
|
||||
enum QuoteState {
|
||||
QUOTE_STATE_UNSPECIFIED = 0;
|
||||
QUOTE_STATE_INDICATIVE = 1;
|
||||
@@ -20,6 +21,7 @@ enum QuoteState {
|
||||
QUOTE_STATE_EXPIRED = 4;
|
||||
}
|
||||
|
||||
// QuoteBlockReason explains why a quote cannot be executed.
|
||||
enum QuoteBlockReason {
|
||||
QUOTE_BLOCK_REASON_UNSPECIFIED = 0;
|
||||
QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1;
|
||||
@@ -31,6 +33,7 @@ enum QuoteBlockReason {
|
||||
QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE = 7;
|
||||
}
|
||||
|
||||
// QuoteExecutionReadiness indicates how readily a quote can be executed.
|
||||
enum QuoteExecutionReadiness {
|
||||
QUOTE_EXECUTION_READINESS_UNSPECIFIED = 0;
|
||||
QUOTE_EXECUTION_READINESS_LIQUIDITY_READY = 1;
|
||||
@@ -38,6 +41,7 @@ enum QuoteExecutionReadiness {
|
||||
QUOTE_EXECUTION_READINESS_INDICATIVE = 3;
|
||||
}
|
||||
|
||||
// RouteHopRole classifies a hop's position in the payment route.
|
||||
enum RouteHopRole {
|
||||
ROUTE_HOP_ROLE_UNSPECIFIED = 0;
|
||||
ROUTE_HOP_ROLE_SOURCE = 1;
|
||||
@@ -45,12 +49,14 @@ enum RouteHopRole {
|
||||
ROUTE_HOP_ROLE_DESTINATION = 3;
|
||||
}
|
||||
|
||||
// FeeTreatment determines how fees are applied to the transfer amount.
|
||||
enum FeeTreatment {
|
||||
FEE_TREATMENT_UNSPECIFIED = 0;
|
||||
FEE_TREATMENT_ADD_TO_SOURCE = 1;
|
||||
FEE_TREATMENT_DEDUCT_FROM_DESTINATION = 2;
|
||||
}
|
||||
|
||||
// RouteHop represents a single step in the payment route topology.
|
||||
message RouteHop {
|
||||
uint32 index = 1;
|
||||
string rail = 2;
|
||||
@@ -60,6 +66,7 @@ message RouteHop {
|
||||
RouteHopRole role = 6;
|
||||
}
|
||||
|
||||
// RouteSettlement describes the settlement asset and model for a route.
|
||||
message RouteSettlement {
|
||||
common.payment.v1.ChainAsset asset = 1;
|
||||
string model = 2;
|
||||
@@ -91,6 +98,7 @@ message ExecutionConditions {
|
||||
repeated string assumptions = 7;
|
||||
}
|
||||
|
||||
// PaymentQuote is a priced, time-bound quote for a single payment intent.
|
||||
message PaymentQuote {
|
||||
common.storable.v1.Storable storable = 1;
|
||||
QuoteState state = 2;
|
||||
|
||||
@@ -10,6 +10,7 @@ import "api/proto/common/payment/v1/settlement.proto";
|
||||
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
||||
import "api/proto/payments/quotation/v2/interface.proto";
|
||||
|
||||
// QuoteIntent describes the intent behind a v2 quote request.
|
||||
message QuoteIntent {
|
||||
payments.endpoint.v1.PaymentEndpoint source = 1;
|
||||
payments.endpoint.v1.PaymentEndpoint destination = 2;
|
||||
@@ -20,6 +21,7 @@ message QuoteIntent {
|
||||
string comment = 7;
|
||||
}
|
||||
|
||||
// QuotePaymentRequest is the request to quote a single v2 payment.
|
||||
message QuotePaymentRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -28,11 +30,13 @@ message QuotePaymentRequest {
|
||||
string initiator_ref = 5;
|
||||
}
|
||||
|
||||
// QuotePaymentResponse is the response for QuotePayment.
|
||||
message QuotePaymentResponse {
|
||||
payments.quotation.v2.PaymentQuote quote = 1;
|
||||
string idempotency_key = 2;
|
||||
}
|
||||
|
||||
// QuotePaymentsRequest is the request to quote multiple v2 payments in a batch.
|
||||
message QuotePaymentsRequest {
|
||||
payments.shared.v1.RequestMeta meta = 1;
|
||||
string idempotency_key = 2;
|
||||
@@ -41,13 +45,14 @@ message QuotePaymentsRequest {
|
||||
string initiator_ref = 5;
|
||||
}
|
||||
|
||||
// QuotePaymentsResponse is the response for QuotePayments.
|
||||
message QuotePaymentsResponse {
|
||||
string quote_ref = 1;
|
||||
repeated payments.quotation.v2.PaymentQuote quotes = 3;
|
||||
string idempotency_key = 4;
|
||||
}
|
||||
|
||||
// Quotation service interface
|
||||
// QuotationService provides v2 payment quoting capabilities.
|
||||
service QuotationService {
|
||||
// QuotePayment returns a quote for a single payment request.
|
||||
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
|
||||
|
||||
@@ -14,6 +14,7 @@ import "api/proto/billing/fees/v1/fees.proto";
|
||||
import "api/proto/gateway/chain/v1/chain.proto";
|
||||
import "api/proto/oracle/v1/oracle.proto";
|
||||
|
||||
// PaymentKind classifies the type of payment operation.
|
||||
enum PaymentKind {
|
||||
PAYMENT_KIND_UNSPECIFIED = 0;
|
||||
PAYMENT_KIND_PAYOUT = 1;
|
||||
@@ -21,6 +22,7 @@ enum PaymentKind {
|
||||
PAYMENT_KIND_FX_CONVERSION = 3;
|
||||
}
|
||||
|
||||
// PaymentState tracks the lifecycle of a payment.
|
||||
enum PaymentState {
|
||||
PAYMENT_STATE_UNSPECIFIED = 0;
|
||||
PAYMENT_STATE_ACCEPTED = 1;
|
||||
@@ -31,6 +33,7 @@ enum PaymentState {
|
||||
PAYMENT_STATE_CANCELLED = 6;
|
||||
}
|
||||
|
||||
// PaymentFailureCode categorises the reason for a payment failure.
|
||||
enum PaymentFailureCode {
|
||||
FAILURE_UNSPECIFIED = 0;
|
||||
FAILURE_BALANCE = 1;
|
||||
@@ -41,21 +44,26 @@ enum PaymentFailureCode {
|
||||
FAILURE_POLICY = 6;
|
||||
}
|
||||
|
||||
// RequestMeta carries organisation context and tracing information for
|
||||
// every payment service request.
|
||||
message RequestMeta {
|
||||
string organization_ref = 1;
|
||||
common.trace.v1.TraceContext trace = 2;
|
||||
}
|
||||
|
||||
// LedgerEndpoint identifies a source or destination on the internal ledger.
|
||||
message LedgerEndpoint {
|
||||
string ledger_account_ref = 1;
|
||||
string contra_ledger_account_ref = 2;
|
||||
}
|
||||
|
||||
// ManagedWalletEndpoint identifies a platform-managed blockchain wallet.
|
||||
message ManagedWalletEndpoint {
|
||||
string managed_wallet_ref = 1;
|
||||
chain.gateway.v1.Asset asset = 2;
|
||||
}
|
||||
|
||||
// ExternalChainEndpoint identifies an external blockchain address.
|
||||
message ExternalChainEndpoint {
|
||||
chain.gateway.v1.Asset asset = 1;
|
||||
string address = 2;
|
||||
@@ -76,6 +84,7 @@ message CardEndpoint {
|
||||
string masked_pan = 8;
|
||||
}
|
||||
|
||||
// PaymentEndpoint is a polymorphic endpoint that can target any supported rail.
|
||||
message PaymentEndpoint {
|
||||
oneof endpoint {
|
||||
LedgerEndpoint ledger = 1;
|
||||
@@ -87,6 +96,7 @@ message PaymentEndpoint {
|
||||
string instance_id = 11;
|
||||
}
|
||||
|
||||
// FXIntent describes the foreign-exchange requirements for a payment.
|
||||
message FXIntent {
|
||||
common.fx.v1.CurrencyPair pair = 1;
|
||||
common.fx.v1.Side side = 2;
|
||||
@@ -96,6 +106,8 @@ message FXIntent {
|
||||
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 {
|
||||
PaymentKind kind = 1;
|
||||
PaymentEndpoint source = 2;
|
||||
@@ -111,6 +123,8 @@ message PaymentIntent {
|
||||
string ref = 12;
|
||||
}
|
||||
|
||||
// Customer holds payer identity and address details for compliance and
|
||||
// routing purposes.
|
||||
message Customer {
|
||||
string id = 1;
|
||||
string first_name = 2;
|
||||
@@ -124,6 +138,8 @@ message Customer {
|
||||
string address = 10;
|
||||
}
|
||||
|
||||
// PaymentQuote captures the pricing snapshot for a payment including
|
||||
// debit amount, expected settlement, fees, and FX details.
|
||||
message PaymentQuote {
|
||||
common.money.v1.Money debit_amount = 1;
|
||||
common.money.v1.Money expected_settlement_amount = 2;
|
||||
@@ -136,6 +152,7 @@ message PaymentQuote {
|
||||
common.money.v1.Money debit_settlement_amount = 9;
|
||||
}
|
||||
|
||||
// PaymentQuoteAggregate summarises totals across multiple payment quotes.
|
||||
message PaymentQuoteAggregate {
|
||||
repeated common.money.v1.Money debit_amounts = 1;
|
||||
repeated common.money.v1.Money expected_settlement_amounts = 2;
|
||||
@@ -143,6 +160,8 @@ message PaymentQuoteAggregate {
|
||||
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 {
|
||||
string debit_entry_ref = 1;
|
||||
string credit_entry_ref = 2;
|
||||
@@ -152,6 +171,7 @@ message ExecutionRefs {
|
||||
string fee_transfer_ref = 6;
|
||||
}
|
||||
|
||||
// ExecutionStep describes a single operational step in the legacy execution plan.
|
||||
message ExecutionStep {
|
||||
string code = 1;
|
||||
string description = 2;
|
||||
@@ -164,11 +184,13 @@ message ExecutionStep {
|
||||
string operation_ref = 9;
|
||||
}
|
||||
|
||||
// ExecutionPlan is the legacy ordered list of steps for fulfilling a payment.
|
||||
message ExecutionPlan {
|
||||
repeated ExecutionStep steps = 1;
|
||||
common.money.v1.Money total_network_fee = 2;
|
||||
}
|
||||
|
||||
// PaymentStep is a single rail-level operation within a PaymentPlan.
|
||||
message PaymentStep {
|
||||
common.gateway.v1.Rail rail = 1;
|
||||
string gateway_id = 2; // required for external rails
|
||||
@@ -181,6 +203,8 @@ message PaymentStep {
|
||||
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 {
|
||||
string id = 1;
|
||||
repeated PaymentStep steps = 2;
|
||||
@@ -202,6 +226,8 @@ message CardPayout {
|
||||
string gateway_reference = 8;
|
||||
}
|
||||
|
||||
// Payment is the top-level aggregate representing a payment throughout its
|
||||
// lifecycle, from initiation through settlement or failure.
|
||||
message Payment {
|
||||
string payment_ref = 1;
|
||||
string idempotency_key = 2;
|
||||
|
||||
@@ -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/payments/endpoint/v1/endpoint.proto";
|
||||
|
||||
|
||||
// -------------------------
|
||||
// Base value movement
|
||||
// -------------------------
|
||||
// TransferIntent describes a value movement between two payment endpoints.
|
||||
message TransferIntent {
|
||||
// source is the originating payment endpoint.
|
||||
payments.endpoint.v1.PaymentEndpoint source = 1;
|
||||
// destination is the receiving payment endpoint.
|
||||
payments.endpoint.v1.PaymentEndpoint destination = 2;
|
||||
// amount is the monetary value to transfer.
|
||||
common.money.v1.Money amount = 3;
|
||||
|
||||
// comment is an optional human-readable note for the transfer.
|
||||
string comment = 4;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ syntax = "proto3";
|
||||
|
||||
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 {
|
||||
// RequestType classifies the kind of site request.
|
||||
enum RequestType {
|
||||
REQUEST_TYPE_UNSPECIFIED = 0;
|
||||
REQUEST_TYPE_DEMO = 1;
|
||||
@@ -10,15 +13,20 @@ message SiteRequestEvent {
|
||||
REQUEST_TYPE_CALL = 3;
|
||||
}
|
||||
|
||||
// type identifies which kind of request was submitted.
|
||||
RequestType type = 1;
|
||||
|
||||
oneof payload {
|
||||
// demo is the payload for a product demo request.
|
||||
SiteDemoRequest demo = 2;
|
||||
// contact is the payload for a general contact inquiry.
|
||||
SiteContactRequest contact = 3;
|
||||
// call is the payload for a callback request.
|
||||
SiteCallRequest call = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// SiteDemoRequest carries details for a product demo request.
|
||||
message SiteDemoRequest {
|
||||
string name = 1;
|
||||
string organization_name = 2;
|
||||
@@ -28,6 +36,7 @@ message SiteDemoRequest {
|
||||
string comment = 6;
|
||||
}
|
||||
|
||||
// SiteContactRequest carries details for a general contact inquiry.
|
||||
message SiteContactRequest {
|
||||
string name = 1;
|
||||
string email = 2;
|
||||
@@ -37,6 +46,7 @@ message SiteContactRequest {
|
||||
string message = 6;
|
||||
}
|
||||
|
||||
// SiteCallRequest carries details for a callback request.
|
||||
message SiteCallRequest {
|
||||
string name = 1;
|
||||
string phone = 2;
|
||||
|
||||
Reference in New Issue
Block a user