refactored notificatoin / tgsettle responsibility boundaries #534
@@ -38,6 +38,6 @@ messaging:
|
|||||||
gateway:
|
gateway:
|
||||||
rail: "provider_settlement"
|
rail: "provider_settlement"
|
||||||
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
|
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
|
||||||
timeout_seconds: 259200
|
timeout_seconds: 345600
|
||||||
accepted_user_ids: []
|
accepted_user_ids: []
|
||||||
success_reaction: "\U0001FAE1"
|
success_reaction: "\U0001FAE1"
|
||||||
|
|||||||
@@ -38,6 +38,6 @@ messaging:
|
|||||||
gateway:
|
gateway:
|
||||||
rail: "provider_settlement"
|
rail: "provider_settlement"
|
||||||
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
|
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
|
||||||
timeout_seconds: 259200
|
timeout_seconds: 345600
|
||||||
accepted_user_ids: []
|
accepted_user_ids: []
|
||||||
success_reaction: "\U0001FAE1"
|
success_reaction: "\U0001FAE1"
|
||||||
|
|||||||
@@ -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"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
|
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
|
||||||
@@ -36,8 +37,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultConfirmationTimeoutSeconds = 259200
|
defaultConfirmationTimeoutSeconds = 345600
|
||||||
defaultTelegramSuccessReaction = "\U0001FAE1"
|
defaultTelegramSuccessReaction = "\U0001FAE1"
|
||||||
|
defaultConfirmationSweepInterval = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -74,6 +76,9 @@ type Service struct {
|
|||||||
outbox gatewayoutbox.ReliableRuntime
|
outbox gatewayoutbox.ReliableRuntime
|
||||||
|
|
||||||
consumers []msg.Consumer
|
consumers []msg.Consumer
|
||||||
|
timeoutCtx context.Context
|
||||||
|
timeoutCancel context.CancelFunc
|
||||||
|
timeoutWG sync.WaitGroup
|
||||||
|
|
||||||
connectorv1.UnimplementedConnectorServiceServer
|
connectorv1.UnimplementedConnectorServiceServer
|
||||||
}
|
}
|
||||||
@@ -103,6 +108,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
}
|
}
|
||||||
svc.startConsumers()
|
svc.startConsumers()
|
||||||
svc.startAnnouncer()
|
svc.startAnnouncer()
|
||||||
|
svc.startConfirmationTimeoutWatcher()
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +131,10 @@ func (s *Service) Shutdown() {
|
|||||||
consumer.Close()
|
consumer.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.timeoutCancel != nil {
|
||||||
|
s.timeoutCancel()
|
||||||
|
}
|
||||||
|
s.timeoutWG.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) startConsumers() {
|
func (s *Service) startConsumers() {
|
||||||
@@ -136,6 +146,10 @@ func (s *Service) startConsumers() {
|
|||||||
}
|
}
|
||||||
resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult)
|
resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult)
|
||||||
s.consumeProcessor(resultProcessor)
|
s.consumeProcessor(resultProcessor)
|
||||||
|
dispatchProcessor := confirmations.NewConfirmationDispatchProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationDispatch)
|
||||||
|
s.consumeProcessor(dispatchProcessor)
|
||||||
|
updateProcessor := tnotifications.NewTelegramUpdateProcessor(s.logger, s.onTelegramUpdate)
|
||||||
|
s.consumeProcessor(updateProcessor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) {
|
func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) {
|
||||||
@@ -300,8 +314,13 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte
|
|||||||
zap.String("idempotency_key", confirmReq.RequestID), zap.String("intent_ref", record.IntentRef))
|
zap.String("idempotency_key", confirmReq.RequestID), zap.String("intent_ref", record.IntentRef))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.persistPendingConfirmation(ctx, confirmReq); err != nil {
|
||||||
|
s.logger.Warn("Failed to persist pending confirmation", zap.Error(err), zap.String("request_id", confirmReq.RequestID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.sendConfirmationRequest(confirmReq); err != nil {
|
if err := s.sendConfirmationRequest(confirmReq); err != nil {
|
||||||
s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
|
s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
|
||||||
|
_ = s.clearPendingConfirmation(ctx, confirmReq.RequestID)
|
||||||
// If the confirmation request was not sent, we keep the record in waiting
|
// If the confirmation request was not sent, we keep the record in waiting
|
||||||
// (or it can be marked as failed — depending on your semantics).
|
// (or it can be marked as failed — depending on your semantics).
|
||||||
// Here, failed is chosen to avoid it hanging indefinitely.
|
// Here, failed is chosen to avoid it hanging indefinitely.
|
||||||
@@ -392,6 +411,10 @@ func (s *Service) onConfirmationResult(ctx context.Context, result *model.Confir
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isFinalConfirmationStatus(result.Status) {
|
||||||
|
_ = s.clearPendingConfirmation(ctx, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
s.publishTelegramReaction(result)
|
s.publishTelegramReaction(result)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||||
@@ -61,6 +62,7 @@ func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.Teleg
|
|||||||
type fakeRepo struct {
|
type fakeRepo struct {
|
||||||
payments *fakePaymentsStore
|
payments *fakePaymentsStore
|
||||||
tg *fakeTelegramStore
|
tg *fakeTelegramStore
|
||||||
|
pending *fakePendingStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
||||||
@@ -71,6 +73,93 @@ func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
|
|||||||
return f.tg
|
return f.tg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
|
||||||
|
return f.pending
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakePendingStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records map[string]*storagemodel.PendingConfirmation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePendingStore) Upsert(_ context.Context, record *storagemodel.PendingConfirmation) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.records == nil {
|
||||||
|
f.records = map[string]*storagemodel.PendingConfirmation{}
|
||||||
|
}
|
||||||
|
cp := *record
|
||||||
|
f.records[record.RequestID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePendingStore) FindByRequestID(_ context.Context, requestID string) (*storagemodel.PendingConfirmation, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.records == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return f.records[requestID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePendingStore) FindByMessageID(_ context.Context, messageID string) (*storagemodel.PendingConfirmation, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, record := range f.records {
|
||||||
|
if record != nil && record.MessageID == messageID {
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePendingStore) MarkClarified(_ context.Context, requestID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if record := f.records[requestID]; record != nil {
|
||||||
|
record.Clarified = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePendingStore) AttachMessage(_ context.Context, requestID string, messageID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if record := f.records[requestID]; record != nil {
|
||||||
|
if record.MessageID == "" {
|
||||||
|
record.MessageID = messageID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.records, requestID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]*storagemodel.PendingConfirmation, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
result := make([]*storagemodel.PendingConfirmation, 0)
|
||||||
|
for _, record := range f.records {
|
||||||
|
if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := *record
|
||||||
|
result = append(result, &cp)
|
||||||
|
if int64(len(result)) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
|
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
|
||||||
//
|
//
|
||||||
@@ -119,6 +208,7 @@ func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
|
|||||||
repo := &fakeRepo{
|
repo := &fakeRepo{
|
||||||
payments: &fakePaymentsStore{},
|
payments: &fakePaymentsStore{},
|
||||||
tg: &fakeTelegramStore{},
|
tg: &fakeTelegramStore{},
|
||||||
|
pending: &fakePendingStore{},
|
||||||
}
|
}
|
||||||
|
|
||||||
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
|
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
|
||||||
|
|||||||
@@ -48,3 +48,18 @@ type TelegramConfirmation struct {
|
|||||||
RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"`
|
RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"`
|
||||||
ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"`
|
ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingConfirmation struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||||
|
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
||||||
|
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
|
||||||
|
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
|
||||||
|
AcceptedUserIDs []string `bson:"acceptedUserIds,omitempty" json:"accepted_user_ids,omitempty"`
|
||||||
|
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
|
||||||
|
SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"`
|
||||||
|
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
|
||||||
|
Clarified bool `bson:"clarified,omitempty" json:"clarified,omitempty"`
|
||||||
|
ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"`
|
||||||
|
CreatedAt time.Time `bson:"createdAt,omitempty" json:"created_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Repository struct {
|
|||||||
|
|
||||||
payments storage.PaymentsStore
|
payments storage.PaymentsStore
|
||||||
tg storage.TelegramConfirmationsStore
|
tg storage.TelegramConfirmationsStore
|
||||||
|
pending storage.PendingConfirmationsStore
|
||||||
outbox gatewayoutbox.Store
|
outbox gatewayoutbox.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +69,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
|||||||
result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations"))
|
result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations"))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
pendingStore, err := store.NewPendingConfirmations(result.logger, result.db)
|
||||||
|
if err != nil {
|
||||||
|
result.logger.Error("Failed to initialise pending confirmations store", zap.Error(err), zap.String("store", "pending_confirmations"))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
|
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
|
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
|
||||||
@@ -75,6 +81,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
|||||||
}
|
}
|
||||||
result.payments = paymentsStore
|
result.payments = paymentsStore
|
||||||
result.tg = tgStore
|
result.tg = tgStore
|
||||||
|
result.pending = pendingStore
|
||||||
result.outbox = outboxStore
|
result.outbox = outboxStore
|
||||||
result.logger.Info("Payment gateway MongoDB storage initialised")
|
result.logger.Info("Payment gateway MongoDB storage initialised")
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -88,6 +95,10 @@ func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore
|
|||||||
return r.tg
|
return r.tg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore {
|
||||||
|
return r.pending
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) Outbox() gatewayoutbox.Store {
|
func (r *Repository) Outbox() gatewayoutbox.Store {
|
||||||
return r.outbox
|
return r.outbox
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
@@ -12,6 +13,7 @@ var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate reco
|
|||||||
type Repository interface {
|
type Repository interface {
|
||||||
Payments() PaymentsStore
|
Payments() PaymentsStore
|
||||||
TelegramConfirmations() TelegramConfirmationsStore
|
TelegramConfirmations() TelegramConfirmationsStore
|
||||||
|
PendingConfirmations() PendingConfirmationsStore
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaymentsStore interface {
|
type PaymentsStore interface {
|
||||||
@@ -22,3 +24,13 @@ type PaymentsStore interface {
|
|||||||
type TelegramConfirmationsStore interface {
|
type TelegramConfirmationsStore interface {
|
||||||
Upsert(ctx context.Context, record *model.TelegramConfirmation) error
|
Upsert(ctx context.Context, record *model.TelegramConfirmation) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingConfirmationsStore interface {
|
||||||
|
Upsert(ctx context.Context, record *model.PendingConfirmation) error
|
||||||
|
FindByRequestID(ctx context.Context, requestID string) (*model.PendingConfirmation, error)
|
||||||
|
FindByMessageID(ctx context.Context, messageID string) (*model.PendingConfirmation, error)
|
||||||
|
MarkClarified(ctx context.Context, requestID string) error
|
||||||
|
AttachMessage(ctx context.Context, requestID string, messageID string) error
|
||||||
|
DeleteByRequestID(ctx context.Context, requestID string) error
|
||||||
|
ListExpired(ctx context.Context, now time.Time, limit int64) ([]*model.PendingConfirmation, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/discovery"
|
||||||
"github.com/tech/sendico/pkg/domainprovider"
|
"github.com/tech/sendico/pkg/domainprovider"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
na "github.com/tech/sendico/pkg/messaging/notifications/account"
|
na "github.com/tech/sendico/pkg/messaging/notifications/account"
|
||||||
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
|
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
|
||||||
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
|
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
|
||||||
@@ -27,8 +28,8 @@ type NotificationAPI struct {
|
|||||||
client mmail.Client
|
client mmail.Client
|
||||||
dp domainprovider.DomainProvider
|
dp domainprovider.DomainProvider
|
||||||
tg telegram.Client
|
tg telegram.Client
|
||||||
|
producer msg.Producer
|
||||||
announcer *discovery.Announcer
|
announcer *discovery.Announcer
|
||||||
confirm *confirmationManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *NotificationAPI) Name() mservice.Type {
|
func (a *NotificationAPI) Name() mservice.Type {
|
||||||
@@ -39,9 +40,6 @@ func (a *NotificationAPI) Finish(_ context.Context) error {
|
|||||||
if a.announcer != nil {
|
if a.announcer != nil {
|
||||||
a.announcer.Stop()
|
a.announcer.Stop()
|
||||||
}
|
}
|
||||||
if a.confirm != nil {
|
|
||||||
a.confirm.Stop()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +48,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
|||||||
dp: a.DomainProvider(),
|
dp: a.DomainProvider(),
|
||||||
}
|
}
|
||||||
p.logger = a.Logger().Named(p.Name())
|
p.logger = a.Logger().Named(p.Name())
|
||||||
|
p.producer = a.Register().Producer()
|
||||||
|
|
||||||
if a.Config().Notification == nil {
|
if a.Config().Notification == nil {
|
||||||
return nil, merrors.InvalidArgument("notification configuration is missing", "config.notification")
|
return nil, merrors.InvalidArgument("notification configuration is missing", "config.notification")
|
||||||
@@ -67,7 +66,6 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
|||||||
p.logger.Error("Failed to create telegram client", zap.Error(err))
|
p.logger.Error("Failed to create telegram client", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.confirm = newConfirmationManager(p.logger, p.tg, a.Register().Producer())
|
|
||||||
|
|
||||||
db, err := a.DBFactory().NewAccountDB()
|
db, err := a.DBFactory().NewAccountDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,6 +90,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
|||||||
p.logger.Error("Failed to register confirmation request handler", zap.Error(err))
|
p.logger.Error("Failed to register confirmation request handler", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := a.Register().Consumer(tnotifications.NewTelegramTextProcessor(p.logger, p.onTelegramText)); err != nil {
|
||||||
|
p.logger.Error("Failed to register telegram text handler", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := a.Register().Consumer(tnotifications.NewTelegramReactionProcessor(p.logger, p.onTelegramReaction)); err != nil {
|
if err := a.Register().Consumer(tnotifications.NewTelegramReactionProcessor(p.logger, p.onTelegramReaction)); err != nil {
|
||||||
p.logger.Error("Failed to register telegram reaction handler", zap.Error(err))
|
p.logger.Error("Failed to register telegram reaction handler", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -162,10 +164,3 @@ func (a *NotificationAPI) onCallRequest(ctx context.Context, request *model.Call
|
|||||||
a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone))
|
a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *NotificationAPI) onConfirmationRequest(ctx context.Context, request *model.ConfirmationRequest) error {
|
|
||||||
if a.confirm == nil {
|
|
||||||
return merrors.Internal("confirmation manager is not configured")
|
|
||||||
}
|
|
||||||
return a.confirm.HandleRequest(ctx, request)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
|
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
|
||||||
|
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,11 +19,11 @@ func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.R
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.confirm == nil {
|
if a.producer == nil {
|
||||||
if a.logger != nil {
|
if a.logger != nil {
|
||||||
a.logger.Warn("Telegram webhook ignored: confirmation manager is not configured")
|
a.logger.Warn("Telegram webhook ignored: messaging producer is not configured")
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var update telegram.Update
|
var update telegram.Update
|
||||||
@@ -52,6 +55,19 @@ func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.R
|
|||||||
}
|
}
|
||||||
a.logger.Info("Telegram webhook update received", fields...)
|
a.logger.Info("Telegram webhook update received", fields...)
|
||||||
}
|
}
|
||||||
a.confirm.HandleUpdate(r.Context(), &update)
|
payload := &model.TelegramWebhookUpdate{
|
||||||
|
UpdateID: update.UpdateID,
|
||||||
|
}
|
||||||
|
if update.Message != nil {
|
||||||
|
payload.Message = update.Message.ToModel()
|
||||||
|
}
|
||||||
|
env := tnotifications.TelegramUpdate(string(mservice.Notifications), payload)
|
||||||
|
if err := a.producer.SendMessage(env); err != nil {
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Warn("Failed to publish telegram webhook update", zap.Error(err), zap.Int64("update_id", update.UpdateID))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
func main() {
|
||||||
smain.RunServer("main", appversion.Create(), factory)
|
smain.RunServer("main", appversion.Create(), factory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
execute_payment_request_validator
|
||||||
|
Validate meta.organization_ref, meta.trace.idempotency_key, quotation_ref, client_payment_ref shape (idempotency key is in trace: trace.proto (line 10)).
|
||||||
|
|
||||||
|
payment_idempotency_service
|
||||||
|
Fingerprint business payload (org + quotation_ref + client_payment_ref), dedupe create/retry behavior, and mismatch detection (same pattern as quote idempotency).
|
||||||
|
|
||||||
|
quote_snapshot_resolver
|
||||||
|
Resolve quotation_ref, enforce executable/non-expired quote, extract canonical intent_snapshot + quote_snapshot.
|
||||||
|
|
||||||
|
payment_aggregate_factory_v2
|
||||||
|
Create initial aggregate: state=CREATED, version=1, immutable snapshots, and initial step telemetry shell.
|
||||||
|
|
||||||
|
execution_plan_compiler_v2
|
||||||
|
Compile runtime step graph from quote route + execution conditions + intent.
|
||||||
|
Important: do not reuse old route/template lookup path as primary (plan_builder route/template selection) because v2 quote already selected route.
|
||||||
|
|
||||||
|
orchestration_state_machine
|
||||||
|
Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...).
|
||||||
|
|
||||||
|
step_scheduler_runtime
|
||||||
|
Pick runnable steps, manage dependency checks, retries/attempts, and mark blocked/skipped.
|
||||||
|
|
||||||
|
step_executor_registry
|
||||||
|
Rail/action executors (ledger, crypto, provider_settlement, card_payout, observe_confirm) behind interfaces.
|
||||||
|
|
||||||
|
external_event_reconciler
|
||||||
|
Consume async gateway/ledger/card events, map to step updates, append external refs, advance aggregate state.
|
||||||
|
|
||||||
|
payment_repository_v2
|
||||||
|
Persistence with optimistic concurrency (version CAS), plus indexes for:
|
||||||
|
(org,payment_ref), (org,idempotency_key), (org,quotation_ref,created_at), (org,state,created_at).
|
||||||
|
|
||||||
|
payment_query_service_v2
|
||||||
|
GetPayment and ListPayments with v2 filters (states, quotation_ref, created_from/to, cursor) and efficient projections.
|
||||||
|
|
||||||
|
payment_response_mapper_v2
|
||||||
|
Map internal aggregate to proto Payment and enforce response invariants (same role as quote mapper/invariants).
|
||||||
|
|
||||||
|
orchestration_observability
|
||||||
|
Metrics, structured logs, audit timeline per payment and per step attempt.
|
||||||
|
*/
|
||||||
|
|||||||
@@ -36,11 +36,34 @@ func (crn *ConfirmationResultNotification) Serialize() ([]byte, error) {
|
|||||||
return crn.Envelope.Wrap(data)
|
return crn.Envelope.Wrap(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfirmationDispatchNotification struct {
|
||||||
|
messaging.Envelope
|
||||||
|
payload model.ConfirmationRequestDispatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cdn *ConfirmationDispatchNotification) Serialize() ([]byte, error) {
|
||||||
|
data, err := json.Marshal(cdn.payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cdn.Envelope.Wrap(data)
|
||||||
|
}
|
||||||
|
|
||||||
func confirmationRequestEvent() model.NotificationEvent {
|
func confirmationRequestEvent() model.NotificationEvent {
|
||||||
return model.NewNotification(mservice.Notifications, nm.NAConfirmationRequest)
|
return model.NewNotification(mservice.Notifications, nm.NAConfirmationRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmationResultEvent(sourceService, rail string) model.NotificationEvent {
|
func confirmationResultEvent(sourceService, rail string) model.NotificationEvent {
|
||||||
|
sourceService, rail = normalizeSourceRail(sourceService, rail)
|
||||||
|
return model.NewNotification(mservice.Verification, nm.NotificationAction(sourceService+"."+rail))
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmationDispatchEvent(sourceService, rail string) model.NotificationEvent {
|
||||||
|
sourceService, rail = normalizeSourceRail(sourceService, rail)
|
||||||
|
return model.NewNotification(mservice.Verification, nm.NotificationAction(sourceService+"."+rail+".dispatch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSourceRail(sourceService, rail string) (string, string) {
|
||||||
action := strings.TrimSpace(sourceService)
|
action := strings.TrimSpace(sourceService)
|
||||||
if action == "" {
|
if action == "" {
|
||||||
action = "unknown"
|
action = "unknown"
|
||||||
@@ -51,7 +74,7 @@ func confirmationResultEvent(sourceService, rail string) model.NotificationEvent
|
|||||||
rail = "default"
|
rail = "default"
|
||||||
}
|
}
|
||||||
rail = strings.ToLower(rail)
|
rail = strings.ToLower(rail)
|
||||||
return model.NewNotification(mservice.Verification, nm.NotificationAction(action+"."+rail))
|
return action, rail
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfirmationRequestEnvelope(sender string, request *model.ConfirmationRequest) messaging.Envelope {
|
func NewConfirmationRequestEnvelope(sender string, request *model.ConfirmationRequest) messaging.Envelope {
|
||||||
@@ -75,3 +98,14 @@ func NewConfirmationResultEnvelope(sender string, result *model.ConfirmationResu
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewConfirmationDispatchEnvelope(sender string, dispatch *model.ConfirmationRequestDispatch, sourceService, rail string) messaging.Envelope {
|
||||||
|
var payload model.ConfirmationRequestDispatch
|
||||||
|
if dispatch != nil {
|
||||||
|
payload = *dispatch
|
||||||
|
}
|
||||||
|
return &ConfirmationDispatchNotification{
|
||||||
|
Envelope: messaging.CreateEnvelope(sender, confirmationDispatchEvent(sourceService, rail)),
|
||||||
|
payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ func (crp *ConfirmationResultProcessor) GetSubject() model.NotificationEvent {
|
|||||||
return crp.event
|
return crp.event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfirmationDispatchProcessor struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
handler ch.ConfirmationDispatchHandler
|
||||||
|
event model.NotificationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cdp *ConfirmationDispatchProcessor) Process(ctx context.Context, envelope me.Envelope) error {
|
||||||
|
var msg model.ConfirmationRequestDispatch
|
||||||
|
if err := json.Unmarshal(envelope.GetData(), &msg); err != nil {
|
||||||
|
cdp.logger.Warn("Failed to decode confirmation dispatch envelope", zap.Error(err), zap.String("topic", cdp.event.ToString()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cdp.handler == nil {
|
||||||
|
cdp.logger.Warn("Confirmation dispatch handler is not configured", zap.String("topic", cdp.event.ToString()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cdp.handler(ctx, &msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cdp *ConfirmationDispatchProcessor) GetSubject() model.NotificationEvent {
|
||||||
|
return cdp.event
|
||||||
|
}
|
||||||
|
|
||||||
func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor {
|
func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor {
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger = logger.Named("confirmation_request_processor")
|
logger = logger.Named("confirmation_request_processor")
|
||||||
@@ -79,3 +102,14 @@ func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail s
|
|||||||
event: confirmationResultEvent(sourceService, rail),
|
event: confirmationResultEvent(sourceService, rail),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewConfirmationDispatchProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationDispatchHandler) np.EnvelopeProcessor {
|
||||||
|
if logger != nil {
|
||||||
|
logger = logger.Named("confirmation_dispatch_processor")
|
||||||
|
}
|
||||||
|
return &ConfirmationDispatchProcessor{
|
||||||
|
logger: logger,
|
||||||
|
handler: handler,
|
||||||
|
event: confirmationDispatchEvent(sourceService, rail),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,40 @@ func telegramReactionEvent() model.NotificationEvent {
|
|||||||
return model.NewNotification(mservice.Notifications, nm.NATelegramReaction)
|
return model.NewNotification(mservice.Notifications, nm.NATelegramReaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TelegramTextNotification struct {
|
||||||
|
messaging.Envelope
|
||||||
|
payload model.TelegramTextRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttn *TelegramTextNotification) Serialize() ([]byte, error) {
|
||||||
|
data, err := json.Marshal(ttn.payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ttn.Envelope.Wrap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramTextEvent() model.NotificationEvent {
|
||||||
|
return model.NewNotification(mservice.Notifications, nm.NATelegramText)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramUpdateNotification struct {
|
||||||
|
messaging.Envelope
|
||||||
|
payload model.TelegramWebhookUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tun *TelegramUpdateNotification) Serialize() ([]byte, error) {
|
||||||
|
data, err := json.Marshal(tun.payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tun.Envelope.Wrap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramUpdateEvent() model.NotificationEvent {
|
||||||
|
return model.NewNotification(mservice.Notifications, nm.NATelegramUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
func NewTelegramReactionEnvelope(sender string, request *model.TelegramReactionRequest) messaging.Envelope {
|
func NewTelegramReactionEnvelope(sender string, request *model.TelegramReactionRequest) messaging.Envelope {
|
||||||
var payload model.TelegramReactionRequest
|
var payload model.TelegramReactionRequest
|
||||||
if request != nil {
|
if request != nil {
|
||||||
@@ -36,3 +70,25 @@ func NewTelegramReactionEnvelope(sender string, request *model.TelegramReactionR
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewTelegramTextEnvelope(sender string, request *model.TelegramTextRequest) messaging.Envelope {
|
||||||
|
var payload model.TelegramTextRequest
|
||||||
|
if request != nil {
|
||||||
|
payload = *request
|
||||||
|
}
|
||||||
|
return &TelegramTextNotification{
|
||||||
|
Envelope: messaging.CreateEnvelope(sender, telegramTextEvent()),
|
||||||
|
payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTelegramUpdateEnvelope(sender string, update *model.TelegramWebhookUpdate) messaging.Envelope {
|
||||||
|
var payload model.TelegramWebhookUpdate
|
||||||
|
if update != nil {
|
||||||
|
payload = *update
|
||||||
|
}
|
||||||
|
return &TelegramUpdateNotification{
|
||||||
|
Envelope: messaging.CreateEnvelope(sender, telegramUpdateEvent()),
|
||||||
|
payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,3 +45,71 @@ func NewTelegramReactionProcessor(logger mlogger.Logger, handler ch.TelegramReac
|
|||||||
event: telegramReactionEvent(),
|
event: telegramReactionEvent(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TelegramTextProcessor struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
handler ch.TelegramTextHandler
|
||||||
|
event model.NotificationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttp *TelegramTextProcessor) Process(ctx context.Context, envelope me.Envelope) error {
|
||||||
|
var msg model.TelegramTextRequest
|
||||||
|
if err := json.Unmarshal(envelope.GetData(), &msg); err != nil {
|
||||||
|
ttp.logger.Warn("Failed to decode telegram text envelope", zap.Error(err), zap.String("topic", ttp.event.ToString()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ttp.handler == nil {
|
||||||
|
ttp.logger.Warn("Telegram text handler is not configured", zap.String("topic", ttp.event.ToString()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ttp.handler(ctx, &msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttp *TelegramTextProcessor) GetSubject() model.NotificationEvent {
|
||||||
|
return ttp.event
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTelegramTextProcessor(logger mlogger.Logger, handler ch.TelegramTextHandler) np.EnvelopeProcessor {
|
||||||
|
if logger != nil {
|
||||||
|
logger = logger.Named("telegram_text_processor")
|
||||||
|
}
|
||||||
|
return &TelegramTextProcessor{
|
||||||
|
logger: logger,
|
||||||
|
handler: handler,
|
||||||
|
event: telegramTextEvent(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramUpdateProcessor struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
handler ch.TelegramUpdateHandler
|
||||||
|
event model.NotificationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tup *TelegramUpdateProcessor) Process(ctx context.Context, envelope me.Envelope) error {
|
||||||
|
var msg model.TelegramWebhookUpdate
|
||||||
|
if err := json.Unmarshal(envelope.GetData(), &msg); err != nil {
|
||||||
|
tup.logger.Warn("Failed to decode telegram webhook update envelope", zap.Error(err), zap.String("topic", tup.event.ToString()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tup.handler == nil {
|
||||||
|
tup.logger.Warn("Telegram update handler is not configured", zap.String("topic", tup.event.ToString()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tup.handler(ctx, &msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tup *TelegramUpdateProcessor) GetSubject() model.NotificationEvent {
|
||||||
|
return tup.event
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTelegramUpdateProcessor(logger mlogger.Logger, handler ch.TelegramUpdateHandler) np.EnvelopeProcessor {
|
||||||
|
if logger != nil {
|
||||||
|
logger = logger.Named("telegram_update_processor")
|
||||||
|
}
|
||||||
|
return &TelegramUpdateProcessor{
|
||||||
|
logger: logger,
|
||||||
|
handler: handler,
|
||||||
|
event: telegramUpdateEvent(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ func ConfirmationResult(sender string, result *model.ConfirmationResult, sourceS
|
|||||||
return cinternal.NewConfirmationResultEnvelope(sender, result, sourceService, rail)
|
return cinternal.NewConfirmationResultEnvelope(sender, result, sourceService, rail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ConfirmationDispatch(sender string, dispatch *model.ConfirmationRequestDispatch, sourceService, rail string) messaging.Envelope {
|
||||||
|
return cinternal.NewConfirmationDispatchEnvelope(sender, dispatch, sourceService, rail)
|
||||||
|
}
|
||||||
|
|
||||||
func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor {
|
func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor {
|
||||||
return cinternal.NewConfirmationRequestProcessor(logger, handler)
|
return cinternal.NewConfirmationRequestProcessor(logger, handler)
|
||||||
}
|
}
|
||||||
@@ -24,3 +28,7 @@ func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.Confirmat
|
|||||||
func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor {
|
func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor {
|
||||||
return cinternal.NewConfirmationResultProcessor(logger, sourceService, rail, handler)
|
return cinternal.NewConfirmationResultProcessor(logger, sourceService, rail, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewConfirmationDispatchProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationDispatchHandler) np.EnvelopeProcessor {
|
||||||
|
return cinternal.NewConfirmationDispatchProcessor(logger, sourceService, rail, handler)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ import (
|
|||||||
type ConfirmationRequestHandler = func(context.Context, *model.ConfirmationRequest) error
|
type ConfirmationRequestHandler = func(context.Context, *model.ConfirmationRequest) error
|
||||||
|
|
||||||
type ConfirmationResultHandler = func(context.Context, *model.ConfirmationResult) error
|
type ConfirmationResultHandler = func(context.Context, *model.ConfirmationResult) error
|
||||||
|
|
||||||
|
type ConfirmationDispatchHandler = func(context.Context, *model.ConfirmationRequestDispatch) error
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TelegramReactionHandler = func(context.Context, *model.TelegramReactionRequest) error
|
type TelegramReactionHandler = func(context.Context, *model.TelegramReactionRequest) error
|
||||||
|
|
||||||
|
type TelegramTextHandler = func(context.Context, *model.TelegramTextRequest) error
|
||||||
|
|
||||||
|
type TelegramUpdateHandler = func(context.Context, *model.TelegramWebhookUpdate) error
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ func TelegramReaction(sender string, request *model.TelegramReactionRequest) mes
|
|||||||
return tinternal.NewTelegramReactionEnvelope(sender, request)
|
return tinternal.NewTelegramReactionEnvelope(sender, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TelegramText(sender string, request *model.TelegramTextRequest) messaging.Envelope {
|
||||||
|
return tinternal.NewTelegramTextEnvelope(sender, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TelegramUpdate(sender string, update *model.TelegramWebhookUpdate) messaging.Envelope {
|
||||||
|
return tinternal.NewTelegramUpdateEnvelope(sender, update)
|
||||||
|
}
|
||||||
|
|
||||||
func NewTelegramReactionProcessor(logger mlogger.Logger, handler ch.TelegramReactionHandler) np.EnvelopeProcessor {
|
func NewTelegramReactionProcessor(logger mlogger.Logger, handler ch.TelegramReactionHandler) np.EnvelopeProcessor {
|
||||||
return tinternal.NewTelegramReactionProcessor(logger, handler)
|
return tinternal.NewTelegramReactionProcessor(logger, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewTelegramTextProcessor(logger mlogger.Logger, handler ch.TelegramTextHandler) np.EnvelopeProcessor {
|
||||||
|
return tinternal.NewTelegramTextProcessor(logger, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTelegramUpdateProcessor(logger mlogger.Logger, handler ch.TelegramUpdateHandler) np.EnvelopeProcessor {
|
||||||
|
return tinternal.NewTelegramUpdateProcessor(logger, handler)
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,3 +33,13 @@ type ConfirmationResult struct {
|
|||||||
Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"`
|
Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"`
|
||||||
ParseError string `bson:"parseError,omitempty" json:"parse_error,omitempty"`
|
ParseError string `bson:"parseError,omitempty" json:"parse_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmationRequestDispatch is emitted by the notification service after it sends
|
||||||
|
// a confirmation prompt message to Telegram.
|
||||||
|
type ConfirmationRequestDispatch struct {
|
||||||
|
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
||||||
|
ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"`
|
||||||
|
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
|
||||||
|
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
|
||||||
|
SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func FromStringImp(s string) (*NotificationEventImp, error) {
|
|||||||
|
|
||||||
func StringToNotificationAction(s string) (nm.NotificationAction, error) {
|
func StringToNotificationAction(s string) (nm.NotificationAction, error) {
|
||||||
switch nm.NotificationAction(s) {
|
switch nm.NotificationAction(s) {
|
||||||
case nm.NACreated, nm.NAPending, nm.NAUpdated, nm.NAArchived, nm.NADeleted, nm.NAAssigned, nm.NAPasswordReset, nm.NAConfirmationRequest, nm.NATelegramReaction, nm.NAPaymentGatewayIntent, nm.NAPaymentGatewayExecution, nm.NADiscoveryServiceAnnounce, nm.NADiscoveryGatewayAnnounce, nm.NADiscoveryHeartbeat, nm.NADiscoveryLookupRequest, nm.NADiscoveryLookupResponse, nm.NADiscoveryRefreshUI:
|
case nm.NACreated, nm.NAPending, nm.NAUpdated, nm.NAArchived, nm.NADeleted, nm.NAAssigned, nm.NAPasswordReset, nm.NAConfirmationRequest, nm.NATelegramReaction, nm.NATelegramText, nm.NATelegramUpdate, nm.NAPaymentGatewayIntent, nm.NAPaymentGatewayExecution, nm.NADiscoveryServiceAnnounce, nm.NADiscoveryGatewayAnnounce, nm.NADiscoveryHeartbeat, nm.NADiscoveryLookupRequest, nm.NADiscoveryLookupResponse, nm.NADiscoveryRefreshUI:
|
||||||
return nm.NotificationAction(s), nil
|
return nm.NotificationAction(s), nil
|
||||||
default:
|
default:
|
||||||
return "", merrors.DataConflict("invalid Notification action: " + s)
|
return "", merrors.DataConflict("invalid Notification action: " + s)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const (
|
|||||||
|
|
||||||
NAConfirmationRequest NotificationAction = "confirmation.request"
|
NAConfirmationRequest NotificationAction = "confirmation.request"
|
||||||
NATelegramReaction NotificationAction = "telegram.reaction"
|
NATelegramReaction NotificationAction = "telegram.reaction"
|
||||||
|
NATelegramText NotificationAction = "telegram.text"
|
||||||
|
NATelegramUpdate NotificationAction = "telegram.update"
|
||||||
NAPaymentGatewayIntent NotificationAction = "intent.request"
|
NAPaymentGatewayIntent NotificationAction = "intent.request"
|
||||||
NAPaymentGatewayExecution NotificationAction = "execution.result"
|
NAPaymentGatewayExecution NotificationAction = "execution.result"
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) {
|
|||||||
nm.NAPasswordReset,
|
nm.NAPasswordReset,
|
||||||
nm.NAConfirmationRequest,
|
nm.NAConfirmationRequest,
|
||||||
nm.NATelegramReaction,
|
nm.NATelegramReaction,
|
||||||
|
nm.NATelegramText,
|
||||||
|
nm.NATelegramUpdate,
|
||||||
nm.NAPaymentGatewayIntent,
|
nm.NAPaymentGatewayIntent,
|
||||||
nm.NAPaymentGatewayExecution,
|
nm.NAPaymentGatewayExecution,
|
||||||
nm.NADiscoveryServiceAnnounce,
|
nm.NADiscoveryServiceAnnounce,
|
||||||
|
|||||||
@@ -16,3 +16,15 @@ type TelegramReactionRequest struct {
|
|||||||
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
|
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
|
||||||
Emoji string `bson:"emoji,omitempty" json:"emoji,omitempty"`
|
Emoji string `bson:"emoji,omitempty" json:"emoji,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TelegramTextRequest struct {
|
||||||
|
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
||||||
|
ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"`
|
||||||
|
Text string `bson:"text,omitempty" json:"text,omitempty"`
|
||||||
|
ReplyToMessageID string `bson:"replyToMessageId,omitempty" json:"reply_to_message_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramWebhookUpdate struct {
|
||||||
|
UpdateID int64 `bson:"updateId,omitempty" json:"update_id,omitempty"`
|
||||||
|
Message *TelegramMessage `bson:"message,omitempty" json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ syntax = "proto3";
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||||
|
|
||||||
|
// AccountCreatedEvent is published when a new user account is registered.
|
||||||
message AccountCreatedEvent {
|
message AccountCreatedEvent {
|
||||||
|
// account_ref is the unique reference of the newly created account.
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
|
// verification_token is the one-time token used to verify the email address.
|
||||||
string verification_token = 2;
|
string verification_token = 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ message RequestMeta {
|
|||||||
common.trace.v1.TraceContext trace = 2;
|
common.trace.v1.TraceContext trace = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResponseMeta carries tracing context for fee engine responses.
|
||||||
message ResponseMeta {
|
message ResponseMeta {
|
||||||
common.trace.v1.TraceContext trace = 1;
|
common.trace.v1.TraceContext trace = 1;
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,7 @@ message QuoteFeesRequest {
|
|||||||
PolicyOverrides policy = 3;
|
PolicyOverrides policy = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuoteFeesResponse returns derived fee lines and the rules that produced them.
|
||||||
message QuoteFeesResponse {
|
message QuoteFeesResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
repeated DerivedPostingLine lines = 2; // derived fee/tax/spread lines
|
repeated DerivedPostingLine lines = 2; // derived fee/tax/spread lines
|
||||||
@@ -117,6 +119,7 @@ message PrecomputeFeesRequest {
|
|||||||
int64 ttl_ms = 3; // token validity window
|
int64 ttl_ms = 3; // token validity window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrecomputeFeesResponse returns a signed fee token and optional preview lines.
|
||||||
message PrecomputeFeesResponse {
|
message PrecomputeFeesResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
string fee_quote_token = 2; // opaque, signed
|
string fee_quote_token = 2; // opaque, signed
|
||||||
@@ -135,6 +138,7 @@ message ValidateFeeTokenRequest {
|
|||||||
string fee_quote_token = 2;
|
string fee_quote_token = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateFeeTokenResponse returns the validation result and embedded fee data.
|
||||||
message ValidateFeeTokenResponse {
|
message ValidateFeeTokenResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
bool valid = 2;
|
bool valid = 2;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/gateway/v1;gateway
|
|||||||
import "api/proto/common/money/v1/money.proto";
|
import "api/proto/common/money/v1/money.proto";
|
||||||
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
||||||
|
|
||||||
|
// Operation enumerates gateway-level operations that can be performed on a
|
||||||
|
// payment method.
|
||||||
enum Operation {
|
enum Operation {
|
||||||
OPERATION_UNSPECIFIED = 0;
|
OPERATION_UNSPECIFIED = 0;
|
||||||
OPERATION_AUTHORIZE = 1;
|
OPERATION_AUTHORIZE = 1;
|
||||||
@@ -126,6 +127,7 @@ message RailCapabilities {
|
|||||||
bool can_release = 7;
|
bool can_release = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LimitsOverride provides per-currency overrides for global limit settings.
|
||||||
message LimitsOverride {
|
message LimitsOverride {
|
||||||
string max_volume = 1;
|
string max_volume = 1;
|
||||||
string min_amount = 2;
|
string min_amount = 2;
|
||||||
@@ -166,6 +168,7 @@ enum OperationResult {
|
|||||||
OPERATION_RESULT_CANCELLED = 3;
|
OPERATION_RESULT_CANCELLED = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OperationError describes a failure returned by a gateway operation.
|
||||||
message OperationError {
|
message OperationError {
|
||||||
string code = 1;
|
string code = 1;
|
||||||
string message = 2;
|
string message = 2;
|
||||||
@@ -173,6 +176,8 @@ message OperationError {
|
|||||||
bool should_rollback = 4;
|
bool should_rollback = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OperationExecutionStatus reports the result of executing a single gateway
|
||||||
|
// operation, including the settled amount and any error.
|
||||||
message OperationExecutionStatus {
|
message OperationExecutionStatus {
|
||||||
string idempotency_key = 1;
|
string idempotency_key = 1;
|
||||||
string operation_ref = 2;
|
string operation_ref = 2;
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ package common.payment.v1;
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||||
|
|
||||||
|
// CardNetwork identifies a card payment network (scheme).
|
||||||
// -------------------------
|
|
||||||
// Card network (payment system)
|
|
||||||
// -------------------------
|
|
||||||
enum CardNetwork {
|
enum CardNetwork {
|
||||||
CARD_NETWORK_UNSPECIFIED = 0;
|
CARD_NETWORK_UNSPECIFIED = 0;
|
||||||
CARD_NETWORK_VISA = 1;
|
CARD_NETWORK_VISA = 1;
|
||||||
@@ -19,6 +16,7 @@ enum CardNetwork {
|
|||||||
CARD_NETWORK_DISCOVER = 7;
|
CARD_NETWORK_DISCOVER = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CardFundingType classifies the funding source behind a card.
|
||||||
enum CardFundingType {
|
enum CardFundingType {
|
||||||
CARD_FUNDING_UNSPECIFIED = 0;
|
CARD_FUNDING_UNSPECIFIED = 0;
|
||||||
CARD_FUNDING_DEBIT = 1;
|
CARD_FUNDING_DEBIT = 1;
|
||||||
@@ -26,10 +24,8 @@ enum CardFundingType {
|
|||||||
CARD_FUNDING_PREPAID = 3;
|
CARD_FUNDING_PREPAID = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------
|
// RawCardData carries PCI-scope card credentials for tokenisation or
|
||||||
// PCI scope: raw card details
|
// direct processing.
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
message RawCardData {
|
message RawCardData {
|
||||||
string pan = 1;
|
string pan = 1;
|
||||||
uint32 exp_month = 2; // 1–12
|
uint32 exp_month = 2; // 1–12
|
||||||
@@ -37,10 +33,8 @@ message RawCardData {
|
|||||||
string cvv = 4; // optional; often omitted for payouts
|
string cvv = 4; // optional; often omitted for payouts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CardMetadata holds non-sensitive display and routing hints derived from
|
||||||
// -------------------------
|
// card details.
|
||||||
// Safe metadata (display / routing hints)
|
|
||||||
// -------------------------
|
|
||||||
message CardMetadata {
|
message CardMetadata {
|
||||||
string masked_pan = 1; // e.g. 411111******1111
|
string masked_pan = 1; // e.g. 411111******1111
|
||||||
CardNetwork network = 2; // Visa/Mastercard/Mir/...
|
CardNetwork network = 2; // Visa/Mastercard/Mir/...
|
||||||
@@ -49,11 +43,8 @@ message CardMetadata {
|
|||||||
string issuer_name = 5; // display only (if known)
|
string issuer_name = 5; // display only (if known)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CardDetails provides card credentials for a payment operation, either
|
||||||
// -------------------------
|
// as inline raw data or a reference to a stored payment method.
|
||||||
// Card details
|
|
||||||
// Either inline credentials OR reference to stored payment method
|
|
||||||
// -------------------------
|
|
||||||
message CardDetails {
|
message CardDetails {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
|
|
||||||
@@ -67,5 +58,3 @@ message CardDetails {
|
|||||||
|
|
||||||
string billing_country = 6; // ISO 3166-1 alpha-2, if you need it per operation
|
string billing_country = 6; // ISO 3166-1 alpha-2, if you need it per operation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ package common.payment.v1;
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||||
|
|
||||||
|
// CustomPaymentDetails carries an opaque, gateway-specific payment method
|
||||||
|
// encoded as JSON bytes.
|
||||||
message CustomPaymentDetails {
|
message CustomPaymentDetails {
|
||||||
|
// id is the unique identifier for this payment method instance.
|
||||||
string id = 1;
|
string id = 1;
|
||||||
|
// payment_method_json is the raw JSON payload understood by the target gateway.
|
||||||
bytes payment_method_json = 2;
|
bytes payment_method_json = 2;
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,15 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;payment
|
|||||||
|
|
||||||
import "api/proto/gateway/chain/v1/chain.proto";
|
import "api/proto/gateway/chain/v1/chain.proto";
|
||||||
|
|
||||||
|
// ExternalChainDetails describes an external blockchain address as a
|
||||||
|
// payment endpoint.
|
||||||
message ExternalChainDetails {
|
message ExternalChainDetails {
|
||||||
|
// id is the unique identifier for this endpoint instance.
|
||||||
string id = 1;
|
string id = 1;
|
||||||
|
// asset identifies the on-chain token (network + symbol + contract).
|
||||||
chain.gateway.v1.Asset asset = 2;
|
chain.gateway.v1.Asset asset = 2;
|
||||||
|
// address is the destination blockchain address.
|
||||||
string address = 3;
|
string address = 3;
|
||||||
|
// memo is an optional transfer memo or tag required by some chains.
|
||||||
string memo = 4;
|
string memo = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ package common.payment.v1;
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||||
|
|
||||||
|
// LedgerDetails identifies an internal ledger account as a payment endpoint.
|
||||||
message LedgerDetails {
|
message LedgerDetails {
|
||||||
|
// id is the unique identifier for this endpoint instance.
|
||||||
string id = 1;
|
string id = 1;
|
||||||
oneof source {
|
oneof source {
|
||||||
|
// ledger_account_ref is the direct ledger account reference.
|
||||||
string ledger_account_ref = 2;
|
string ledger_account_ref = 2;
|
||||||
|
// account_code is a human-readable account code resolved at runtime.
|
||||||
string account_code = 3;
|
string account_code = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ package common.payment.v1;
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||||
|
|
||||||
|
// ManagedWalletDetails identifies a platform-managed blockchain wallet as a
|
||||||
|
// payment endpoint.
|
||||||
message ManagedWalletDetails {
|
message ManagedWalletDetails {
|
||||||
|
// id is the unique identifier for this endpoint instance.
|
||||||
string id = 1;
|
string id = 1;
|
||||||
|
// managed_wallet_ref is the reference to the managed wallet record.
|
||||||
string managed_wallet_ref = 2;
|
string managed_wallet_ref = 2;
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,15 @@ package common.payment.v1;
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||||
|
|
||||||
// -------------------------
|
// RussianBankDetails carries Russian domestic bank account information for
|
||||||
// Russian bank account details
|
// RUB payouts.
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
message RussianBankDetails {
|
message RussianBankDetails {
|
||||||
|
// id is the unique identifier for this endpoint instance.
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string account_number = 2; // 20 digits
|
// account_number is the 20-digit Russian bank account number.
|
||||||
string bik = 3; // 9 digits
|
string account_number = 2;
|
||||||
|
// bik is the 9-digit Russian bank identification code.
|
||||||
|
string bik = 3;
|
||||||
|
// account_holder_name is the full name of the account holder.
|
||||||
string account_holder_name = 4;
|
string account_holder_name = 4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ package common.payment.v1;
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1";
|
||||||
|
|
||||||
// -------------------------
|
// SepaBankDetails carries SEPA bank account information for EUR transfers.
|
||||||
// SEPA bank account details
|
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
message SepaBankDetails {
|
message SepaBankDetails {
|
||||||
|
// id is the unique identifier for this endpoint instance.
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string iban = 2; // IBAN
|
// iban is the International Bank Account Number.
|
||||||
string bic = 3; // optional (BIC/SWIFT)
|
string iban = 2;
|
||||||
|
// bic is the optional BIC/SWIFT code.
|
||||||
|
string bic = 3;
|
||||||
|
// account_holder_name is the full name of the account holder.
|
||||||
string account_holder_name = 4;
|
string account_holder_name = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/common/storable/v1;storab
|
|||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
|
||||||
|
// Storable carries common persistence metadata (ID and timestamps).
|
||||||
message Storable {
|
message Storable {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
google.protobuf.Timestamp created_at = 10;
|
google.protobuf.Timestamp created_at = 10;
|
||||||
|
|||||||
@@ -4,19 +4,30 @@ import "google/protobuf/timestamp.proto";
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||||
|
|
||||||
|
// NotificationEvent identifies the type and action of an event for routing.
|
||||||
message NotificationEvent {
|
message NotificationEvent {
|
||||||
string type = 1; // NotificationType
|
// type is the notification category (e.g. "payment", "account").
|
||||||
string action = 2; // NotificationAction
|
string type = 1;
|
||||||
|
// action is the specific event action (e.g. "created", "settled").
|
||||||
|
string action = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventMetadata carries provenance information for a published event.
|
||||||
message EventMetadata {
|
message EventMetadata {
|
||||||
|
// sender identifies the originating service.
|
||||||
string sender = 1;
|
string sender = 1;
|
||||||
|
// message_id is the unique identifier of this event message.
|
||||||
string message_id = 2;
|
string message_id = 2;
|
||||||
|
// timestamp is the time the event was published.
|
||||||
google.protobuf.Timestamp timestamp = 3;
|
google.protobuf.Timestamp timestamp = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Envelope wraps a serialised event payload with routing and metadata.
|
||||||
message Envelope {
|
message Envelope {
|
||||||
NotificationEvent event = 2; // Notification event with type and action
|
// event describes the notification type and action for routing.
|
||||||
bytes message_data = 3; // Serialized Protobuf message data
|
NotificationEvent event = 2;
|
||||||
EventMetadata metadata = 4; // Metadata about the event
|
// message_data is the serialised protobuf payload.
|
||||||
|
bytes message_data = 3;
|
||||||
|
// metadata carries provenance information about the event.
|
||||||
|
EventMetadata metadata = 4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,29 +12,47 @@ import "api/proto/common/describable/v1/describable.proto";
|
|||||||
|
|
||||||
// Supported blockchain networks for the managed wallets.
|
// Supported blockchain networks for the managed wallets.
|
||||||
enum ChainNetwork {
|
enum ChainNetwork {
|
||||||
|
// CHAIN_NETWORK_UNSPECIFIED is the default zero value.
|
||||||
CHAIN_NETWORK_UNSPECIFIED = 0;
|
CHAIN_NETWORK_UNSPECIFIED = 0;
|
||||||
|
// CHAIN_NETWORK_ETHEREUM_MAINNET is Ethereum layer-1 mainnet.
|
||||||
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
|
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
|
||||||
|
// CHAIN_NETWORK_ARBITRUM_ONE is the Arbitrum One rollup.
|
||||||
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
||||||
|
// CHAIN_NETWORK_TRON_MAINNET is the TRON mainnet.
|
||||||
CHAIN_NETWORK_TRON_MAINNET = 4;
|
CHAIN_NETWORK_TRON_MAINNET = 4;
|
||||||
|
// CHAIN_NETWORK_TRON_NILE is the TRON Nile testnet.
|
||||||
CHAIN_NETWORK_TRON_NILE = 5;
|
CHAIN_NETWORK_TRON_NILE = 5;
|
||||||
|
// CHAIN_NETWORK_ARBITRUM_SEPOLIA is the Arbitrum Sepolia testnet.
|
||||||
CHAIN_NETWORK_ARBITRUM_SEPOLIA = 6;
|
CHAIN_NETWORK_ARBITRUM_SEPOLIA = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManagedWalletStatus represents the lifecycle state of a managed wallet.
|
||||||
enum ManagedWalletStatus {
|
enum ManagedWalletStatus {
|
||||||
|
// MANAGED_WALLET_STATUS_UNSPECIFIED is the default zero value.
|
||||||
MANAGED_WALLET_STATUS_UNSPECIFIED = 0;
|
MANAGED_WALLET_STATUS_UNSPECIFIED = 0;
|
||||||
|
// MANAGED_WALLET_ACTIVE means the wallet is open and operational.
|
||||||
MANAGED_WALLET_ACTIVE = 1;
|
MANAGED_WALLET_ACTIVE = 1;
|
||||||
|
// MANAGED_WALLET_SUSPENDED means the wallet is temporarily disabled.
|
||||||
MANAGED_WALLET_SUSPENDED = 2;
|
MANAGED_WALLET_SUSPENDED = 2;
|
||||||
|
// MANAGED_WALLET_CLOSED means the wallet is permanently closed.
|
||||||
MANAGED_WALLET_CLOSED = 3;
|
MANAGED_WALLET_CLOSED = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DepositStatus tracks the confirmation state of an inbound deposit.
|
||||||
enum DepositStatus {
|
enum DepositStatus {
|
||||||
|
// DEPOSIT_STATUS_UNSPECIFIED is the default zero value.
|
||||||
DEPOSIT_STATUS_UNSPECIFIED = 0;
|
DEPOSIT_STATUS_UNSPECIFIED = 0;
|
||||||
|
// DEPOSIT_PENDING means the deposit has been observed but not yet confirmed.
|
||||||
DEPOSIT_PENDING = 1;
|
DEPOSIT_PENDING = 1;
|
||||||
|
// DEPOSIT_CONFIRMED means the deposit has been confirmed on-chain.
|
||||||
DEPOSIT_CONFIRMED = 2;
|
DEPOSIT_CONFIRMED = 2;
|
||||||
|
// DEPOSIT_FAILED means the deposit could not be confirmed.
|
||||||
DEPOSIT_FAILED = 3;
|
DEPOSIT_FAILED = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransferStatus tracks the lifecycle of an outbound transfer.
|
||||||
enum TransferStatus {
|
enum TransferStatus {
|
||||||
|
// TRANSFER_STATUS_UNSPECIFIED is the default zero value.
|
||||||
TRANSFER_STATUS_UNSPECIFIED = 0;
|
TRANSFER_STATUS_UNSPECIFIED = 0;
|
||||||
|
|
||||||
TRANSFER_CREATED = 1; // record exists, not started
|
TRANSFER_CREATED = 1; // record exists, not started
|
||||||
@@ -54,6 +72,7 @@ message Asset {
|
|||||||
string contract_address = 3; // optional override when multiple contracts exist per chain
|
string contract_address = 3; // optional override when multiple contracts exist per chain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManagedWallet represents a platform-managed blockchain wallet.
|
||||||
message ManagedWallet {
|
message ManagedWallet {
|
||||||
string wallet_ref = 1;
|
string wallet_ref = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -67,6 +86,7 @@ message ManagedWallet {
|
|||||||
common.describable.v1.Describable describable = 10;
|
common.describable.v1.Describable describable = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateManagedWalletRequest is the request to create a new managed wallet.
|
||||||
message CreateManagedWalletRequest {
|
message CreateManagedWalletRequest {
|
||||||
string idempotency_key = 1;
|
string idempotency_key = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -76,18 +96,22 @@ message CreateManagedWalletRequest {
|
|||||||
common.describable.v1.Describable describable = 6;
|
common.describable.v1.Describable describable = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateManagedWalletResponse is the response for CreateManagedWallet.
|
||||||
message CreateManagedWalletResponse {
|
message CreateManagedWalletResponse {
|
||||||
ManagedWallet wallet = 1;
|
ManagedWallet wallet = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetManagedWalletRequest is the request to retrieve a wallet by reference.
|
||||||
message GetManagedWalletRequest {
|
message GetManagedWalletRequest {
|
||||||
string wallet_ref = 1;
|
string wallet_ref = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetManagedWalletResponse is the response for GetManagedWallet.
|
||||||
message GetManagedWalletResponse {
|
message GetManagedWalletResponse {
|
||||||
ManagedWallet wallet = 1;
|
ManagedWallet wallet = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListManagedWalletsRequest is the request to list wallets with optional filters.
|
||||||
message ListManagedWalletsRequest {
|
message ListManagedWalletsRequest {
|
||||||
string organization_ref = 1;
|
string organization_ref = 1;
|
||||||
reserved 2;
|
reserved 2;
|
||||||
@@ -101,11 +125,13 @@ message ListManagedWalletsRequest {
|
|||||||
google.protobuf.StringValue owner_ref_filter = 5;
|
google.protobuf.StringValue owner_ref_filter = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListManagedWalletsResponse is the response for ListManagedWallets.
|
||||||
message ListManagedWalletsResponse {
|
message ListManagedWalletsResponse {
|
||||||
repeated ManagedWallet wallets = 1;
|
repeated ManagedWallet wallets = 1;
|
||||||
common.pagination.v1.CursorPageResponse page = 2;
|
common.pagination.v1.CursorPageResponse page = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WalletBalance holds the balance breakdown for a managed wallet.
|
||||||
message WalletBalance {
|
message WalletBalance {
|
||||||
common.money.v1.Money available = 1;
|
common.money.v1.Money available = 1;
|
||||||
common.money.v1.Money pending_inbound = 2;
|
common.money.v1.Money pending_inbound = 2;
|
||||||
@@ -114,20 +140,24 @@ message WalletBalance {
|
|||||||
common.money.v1.Money native_available = 5;
|
common.money.v1.Money native_available = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWalletBalanceRequest is the request to retrieve a wallet's balance.
|
||||||
message GetWalletBalanceRequest {
|
message GetWalletBalanceRequest {
|
||||||
string wallet_ref = 1;
|
string wallet_ref = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWalletBalanceResponse is the response for GetWalletBalance.
|
||||||
message GetWalletBalanceResponse {
|
message GetWalletBalanceResponse {
|
||||||
WalletBalance balance = 1;
|
WalletBalance balance = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceFeeBreakdown describes a single fee line item applied to a transfer.
|
||||||
message ServiceFeeBreakdown {
|
message ServiceFeeBreakdown {
|
||||||
string fee_code = 1;
|
string fee_code = 1;
|
||||||
common.money.v1.Money amount = 2;
|
common.money.v1.Money amount = 2;
|
||||||
string description = 3;
|
string description = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransferDestination identifies where a transfer should be sent.
|
||||||
message TransferDestination {
|
message TransferDestination {
|
||||||
oneof destination {
|
oneof destination {
|
||||||
string managed_wallet_ref = 1;
|
string managed_wallet_ref = 1;
|
||||||
@@ -136,6 +166,7 @@ message TransferDestination {
|
|||||||
string memo = 3; // chain-specific memo/tag when required by the destination
|
string memo = 3; // chain-specific memo/tag when required by the destination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transfer represents an outbound blockchain transfer.
|
||||||
message Transfer {
|
message Transfer {
|
||||||
string transfer_ref = 1;
|
string transfer_ref = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -156,6 +187,7 @@ message Transfer {
|
|||||||
string operation_ref = 17;
|
string operation_ref = 17;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitTransferRequest is the request to submit an outbound transfer.
|
||||||
message SubmitTransferRequest {
|
message SubmitTransferRequest {
|
||||||
string idempotency_key = 1;
|
string idempotency_key = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -169,18 +201,22 @@ message SubmitTransferRequest {
|
|||||||
string payment_ref = 10;
|
string payment_ref = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitTransferResponse is the response for SubmitTransfer.
|
||||||
message SubmitTransferResponse {
|
message SubmitTransferResponse {
|
||||||
Transfer transfer = 1;
|
Transfer transfer = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTransferRequest is the request to retrieve a transfer by reference.
|
||||||
message GetTransferRequest {
|
message GetTransferRequest {
|
||||||
string transfer_ref = 1;
|
string transfer_ref = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTransferResponse is the response for GetTransfer.
|
||||||
message GetTransferResponse {
|
message GetTransferResponse {
|
||||||
Transfer transfer = 1;
|
Transfer transfer = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTransfersRequest is the request to list transfers with optional filters.
|
||||||
message ListTransfersRequest {
|
message ListTransfersRequest {
|
||||||
string source_wallet_ref = 1;
|
string source_wallet_ref = 1;
|
||||||
string destination_wallet_ref = 2;
|
string destination_wallet_ref = 2;
|
||||||
@@ -188,11 +224,13 @@ message ListTransfersRequest {
|
|||||||
common.pagination.v1.CursorPageRequest page = 4;
|
common.pagination.v1.CursorPageRequest page = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTransfersResponse is the response for ListTransfers.
|
||||||
message ListTransfersResponse {
|
message ListTransfersResponse {
|
||||||
repeated Transfer transfers = 1;
|
repeated Transfer transfers = 1;
|
||||||
common.pagination.v1.CursorPageResponse page = 2;
|
common.pagination.v1.CursorPageResponse page = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EstimateTransferFeeRequest is the request to estimate network fees for a transfer.
|
||||||
message EstimateTransferFeeRequest {
|
message EstimateTransferFeeRequest {
|
||||||
string source_wallet_ref = 1;
|
string source_wallet_ref = 1;
|
||||||
TransferDestination destination = 2;
|
TransferDestination destination = 2;
|
||||||
@@ -200,21 +238,25 @@ message EstimateTransferFeeRequest {
|
|||||||
Asset asset = 4;
|
Asset asset = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EstimateTransferFeeResponse is the response for EstimateTransferFee.
|
||||||
message EstimateTransferFeeResponse {
|
message EstimateTransferFeeResponse {
|
||||||
common.money.v1.Money network_fee = 1;
|
common.money.v1.Money network_fee = 1;
|
||||||
string estimation_context = 2;
|
string estimation_context = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComputeGasTopUpRequest is the request to calculate the gas top-up needed.
|
||||||
message ComputeGasTopUpRequest {
|
message ComputeGasTopUpRequest {
|
||||||
string wallet_ref = 1;
|
string wallet_ref = 1;
|
||||||
common.money.v1.Money estimated_total_fee = 2;
|
common.money.v1.Money estimated_total_fee = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComputeGasTopUpResponse is the response for ComputeGasTopUp.
|
||||||
message ComputeGasTopUpResponse {
|
message ComputeGasTopUpResponse {
|
||||||
common.money.v1.Money topup_amount = 1;
|
common.money.v1.Money topup_amount = 1;
|
||||||
bool cap_hit = 2;
|
bool cap_hit = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureGasTopUpRequest is the request to top up gas for a wallet if needed.
|
||||||
message EnsureGasTopUpRequest {
|
message EnsureGasTopUpRequest {
|
||||||
string idempotency_key = 1;
|
string idempotency_key = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -227,12 +269,14 @@ message EnsureGasTopUpRequest {
|
|||||||
string operation_ref = 9;
|
string operation_ref = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureGasTopUpResponse is the response for EnsureGasTopUp.
|
||||||
message EnsureGasTopUpResponse {
|
message EnsureGasTopUpResponse {
|
||||||
common.money.v1.Money topup_amount = 1;
|
common.money.v1.Money topup_amount = 1;
|
||||||
bool cap_hit = 2;
|
bool cap_hit = 2;
|
||||||
Transfer transfer = 3;
|
Transfer transfer = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WalletDepositObservedEvent is emitted when a deposit is detected on-chain.
|
||||||
message WalletDepositObservedEvent {
|
message WalletDepositObservedEvent {
|
||||||
string deposit_ref = 1;
|
string deposit_ref = 1;
|
||||||
string wallet_ref = 2;
|
string wallet_ref = 2;
|
||||||
@@ -245,6 +289,7 @@ message WalletDepositObservedEvent {
|
|||||||
google.protobuf.Timestamp observed_at = 9;
|
google.protobuf.Timestamp observed_at = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransferStatusChangedEvent is emitted when a transfer changes status.
|
||||||
message TransferStatusChangedEvent {
|
message TransferStatusChangedEvent {
|
||||||
Transfer transfer = 1;
|
Transfer transfer = 1;
|
||||||
string reason = 2;
|
string reason = 2;
|
||||||
|
|||||||
@@ -72,10 +72,12 @@ message CardPayoutResponse {
|
|||||||
string error_message = 5;
|
string error_message = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCardPayoutStatusRequest fetches the current status of a payout.
|
||||||
message GetCardPayoutStatusRequest {
|
message GetCardPayoutStatusRequest {
|
||||||
string payout_id = 1;
|
string payout_id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCardPayoutStatusResponse returns the current payout state.
|
||||||
message GetCardPayoutStatusResponse {
|
message GetCardPayoutStatusResponse {
|
||||||
CardPayoutState payout = 1;
|
CardPayoutState payout = 1;
|
||||||
}
|
}
|
||||||
@@ -85,8 +87,10 @@ message CardPayoutStatusChangedEvent {
|
|||||||
CardPayoutState payout = 1;
|
CardPayoutState payout = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListGatewayInstancesRequest requests all registered gateway instances.
|
||||||
message ListGatewayInstancesRequest {}
|
message ListGatewayInstancesRequest {}
|
||||||
|
|
||||||
|
// ListGatewayInstancesResponse returns the available gateway instances.
|
||||||
message ListGatewayInstancesResponse {
|
message ListGatewayInstancesResponse {
|
||||||
repeated common.gateway.v1.GatewayInstanceDescriptor items = 1;
|
repeated common.gateway.v1.GatewayInstanceDescriptor items = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,51 +11,89 @@ import "api/proto/common/money/v1/money.proto";
|
|||||||
|
|
||||||
// ===== Enums =====
|
// ===== Enums =====
|
||||||
|
|
||||||
|
// EntryType classifies the kind of journal entry.
|
||||||
enum EntryType {
|
enum EntryType {
|
||||||
|
// ENTRY_TYPE_UNSPECIFIED is the default zero value.
|
||||||
ENTRY_TYPE_UNSPECIFIED = 0;
|
ENTRY_TYPE_UNSPECIFIED = 0;
|
||||||
|
// ENTRY_CREDIT records an inbound credit.
|
||||||
ENTRY_CREDIT = 1;
|
ENTRY_CREDIT = 1;
|
||||||
|
// ENTRY_DEBIT records an outbound debit.
|
||||||
ENTRY_DEBIT = 2;
|
ENTRY_DEBIT = 2;
|
||||||
|
// ENTRY_TRANSFER records a transfer between accounts.
|
||||||
ENTRY_TRANSFER = 3;
|
ENTRY_TRANSFER = 3;
|
||||||
|
// ENTRY_FX records a foreign-exchange conversion.
|
||||||
ENTRY_FX = 4;
|
ENTRY_FX = 4;
|
||||||
|
// ENTRY_FEE records a fee charge.
|
||||||
ENTRY_FEE = 5;
|
ENTRY_FEE = 5;
|
||||||
|
// ENTRY_ADJUST records a manual adjustment.
|
||||||
ENTRY_ADJUST = 6;
|
ENTRY_ADJUST = 6;
|
||||||
|
// ENTRY_REVERSE records a reversal of a prior entry.
|
||||||
ENTRY_REVERSE = 7;
|
ENTRY_REVERSE = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LineType classifies the purpose of a posting line within an entry.
|
||||||
enum LineType {
|
enum LineType {
|
||||||
|
// LINE_TYPE_UNSPECIFIED is the default zero value.
|
||||||
LINE_TYPE_UNSPECIFIED = 0;
|
LINE_TYPE_UNSPECIFIED = 0;
|
||||||
|
// LINE_MAIN is the primary posting line.
|
||||||
LINE_MAIN = 1;
|
LINE_MAIN = 1;
|
||||||
|
// LINE_FEE is a fee posting line.
|
||||||
LINE_FEE = 2;
|
LINE_FEE = 2;
|
||||||
|
// LINE_SPREAD is an FX spread posting line.
|
||||||
LINE_SPREAD = 3;
|
LINE_SPREAD = 3;
|
||||||
|
// LINE_REVERSAL is a reversal posting line.
|
||||||
LINE_REVERSAL = 4;
|
LINE_REVERSAL = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountType classifies the fundamental accounting type of an account.
|
||||||
enum AccountType {
|
enum AccountType {
|
||||||
|
// ACCOUNT_TYPE_UNSPECIFIED is the default zero value.
|
||||||
ACCOUNT_TYPE_UNSPECIFIED = 0;
|
ACCOUNT_TYPE_UNSPECIFIED = 0;
|
||||||
|
// ACCOUNT_TYPE_ASSET represents an asset account.
|
||||||
ACCOUNT_TYPE_ASSET = 1;
|
ACCOUNT_TYPE_ASSET = 1;
|
||||||
|
// ACCOUNT_TYPE_LIABILITY represents a liability account.
|
||||||
ACCOUNT_TYPE_LIABILITY = 2;
|
ACCOUNT_TYPE_LIABILITY = 2;
|
||||||
|
// ACCOUNT_TYPE_REVENUE represents a revenue account.
|
||||||
ACCOUNT_TYPE_REVENUE = 3;
|
ACCOUNT_TYPE_REVENUE = 3;
|
||||||
|
// ACCOUNT_TYPE_EXPENSE represents an expense account.
|
||||||
ACCOUNT_TYPE_EXPENSE = 4;
|
ACCOUNT_TYPE_EXPENSE = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountStatus indicates whether an account is active or frozen.
|
||||||
enum AccountStatus {
|
enum AccountStatus {
|
||||||
|
// ACCOUNT_STATUS_UNSPECIFIED is the default zero value.
|
||||||
ACCOUNT_STATUS_UNSPECIFIED = 0;
|
ACCOUNT_STATUS_UNSPECIFIED = 0;
|
||||||
|
// ACCOUNT_STATUS_ACTIVE means the account accepts postings.
|
||||||
ACCOUNT_STATUS_ACTIVE = 1;
|
ACCOUNT_STATUS_ACTIVE = 1;
|
||||||
|
// ACCOUNT_STATUS_FROZEN means the account is blocked from new postings.
|
||||||
ACCOUNT_STATUS_FROZEN = 2;
|
ACCOUNT_STATUS_FROZEN = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountRole defines the functional role of an account within an organization.
|
||||||
enum AccountRole {
|
enum AccountRole {
|
||||||
|
// ACCOUNT_ROLE_UNSPECIFIED is the default zero value.
|
||||||
ACCOUNT_ROLE_UNSPECIFIED = 0;
|
ACCOUNT_ROLE_UNSPECIFIED = 0;
|
||||||
|
// ACCOUNT_ROLE_OPERATING is the main operating account.
|
||||||
ACCOUNT_ROLE_OPERATING = 1;
|
ACCOUNT_ROLE_OPERATING = 1;
|
||||||
|
// ACCOUNT_ROLE_HOLD is a temporary hold account.
|
||||||
ACCOUNT_ROLE_HOLD = 2;
|
ACCOUNT_ROLE_HOLD = 2;
|
||||||
|
// ACCOUNT_ROLE_TRANSIT is an in-transit account.
|
||||||
ACCOUNT_ROLE_TRANSIT = 3;
|
ACCOUNT_ROLE_TRANSIT = 3;
|
||||||
|
// ACCOUNT_ROLE_SETTLEMENT is a settlement account.
|
||||||
ACCOUNT_ROLE_SETTLEMENT = 4;
|
ACCOUNT_ROLE_SETTLEMENT = 4;
|
||||||
|
// ACCOUNT_ROLE_CLEARING is a clearing account.
|
||||||
ACCOUNT_ROLE_CLEARING = 5;
|
ACCOUNT_ROLE_CLEARING = 5;
|
||||||
|
// ACCOUNT_ROLE_PENDING is a pending-settlement account.
|
||||||
ACCOUNT_ROLE_PENDING = 6;
|
ACCOUNT_ROLE_PENDING = 6;
|
||||||
|
// ACCOUNT_ROLE_RESERVE is a reserve account.
|
||||||
ACCOUNT_ROLE_RESERVE = 7;
|
ACCOUNT_ROLE_RESERVE = 7;
|
||||||
|
// ACCOUNT_ROLE_LIQUIDITY is a liquidity pool account.
|
||||||
ACCOUNT_ROLE_LIQUIDITY = 8;
|
ACCOUNT_ROLE_LIQUIDITY = 8;
|
||||||
|
// ACCOUNT_ROLE_FEE is a fee collection account.
|
||||||
ACCOUNT_ROLE_FEE = 9;
|
ACCOUNT_ROLE_FEE = 9;
|
||||||
|
// ACCOUNT_ROLE_CHARGEBACK is a chargeback account.
|
||||||
ACCOUNT_ROLE_CHARGEBACK = 10;
|
ACCOUNT_ROLE_CHARGEBACK = 10;
|
||||||
|
// ACCOUNT_ROLE_ADJUSTMENT is an adjustment account.
|
||||||
ACCOUNT_ROLE_ADJUSTMENT = 11;
|
ACCOUNT_ROLE_ADJUSTMENT = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +125,7 @@ message PostingLine {
|
|||||||
|
|
||||||
// ===== Requests/Responses =====
|
// ===== Requests/Responses =====
|
||||||
|
|
||||||
|
// CreateAccountRequest is the request to create a new ledger account.
|
||||||
message CreateAccountRequest {
|
message CreateAccountRequest {
|
||||||
string organization_ref = 1;
|
string organization_ref = 1;
|
||||||
string owner_ref = 2;
|
string owner_ref = 2;
|
||||||
@@ -103,6 +142,7 @@ message CreateAccountRequest {
|
|||||||
AccountRole role = 11;
|
AccountRole role = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateAccountResponse is the response for CreateAccount.
|
||||||
message CreateAccountResponse {
|
message CreateAccountResponse {
|
||||||
LedgerAccount account = 1;
|
LedgerAccount account = 1;
|
||||||
}
|
}
|
||||||
@@ -121,6 +161,7 @@ message PostCreditRequest {
|
|||||||
AccountRole role = 10; // optional: assert target account has this role
|
AccountRole role = 10; // optional: assert target account has this role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostDebitRequest is the request to post a debit entry.
|
||||||
message PostDebitRequest {
|
message PostDebitRequest {
|
||||||
string idempotency_key = 1;
|
string idempotency_key = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -134,6 +175,7 @@ message PostDebitRequest {
|
|||||||
AccountRole role = 10; // optional: assert target account has this role
|
AccountRole role = 10; // optional: assert target account has this role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransferRequest is the request to transfer funds between two ledger accounts.
|
||||||
message TransferRequest {
|
message TransferRequest {
|
||||||
string idempotency_key = 1;
|
string idempotency_key = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -148,6 +190,7 @@ message TransferRequest {
|
|||||||
AccountRole to_role = 11;
|
AccountRole to_role = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FXRequest is the request to post a foreign-exchange conversion entry.
|
||||||
message FXRequest {
|
message FXRequest {
|
||||||
string idempotency_key = 1;
|
string idempotency_key = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -164,6 +207,7 @@ message FXRequest {
|
|||||||
google.protobuf.Timestamp event_time = 11;
|
google.protobuf.Timestamp event_time = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostResponse is the common response returned after any posting operation.
|
||||||
message PostResponse {
|
message PostResponse {
|
||||||
string journal_entry_ref = 1;
|
string journal_entry_ref = 1;
|
||||||
int64 version = 2; // ledger's entry version (monotonic per scope)
|
int64 version = 2; // ledger's entry version (monotonic per scope)
|
||||||
@@ -172,10 +216,12 @@ message PostResponse {
|
|||||||
|
|
||||||
// ---- Balances & Entries ----
|
// ---- Balances & Entries ----
|
||||||
|
|
||||||
|
// GetBalanceRequest is the request to retrieve an account balance.
|
||||||
message GetBalanceRequest {
|
message GetBalanceRequest {
|
||||||
string ledger_account_ref = 1;
|
string ledger_account_ref = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BalanceResponse holds the current balance of a ledger account.
|
||||||
message BalanceResponse {
|
message BalanceResponse {
|
||||||
string ledger_account_ref = 1;
|
string ledger_account_ref = 1;
|
||||||
common.money.v1.Money balance = 2;
|
common.money.v1.Money balance = 2;
|
||||||
@@ -183,10 +229,12 @@ message BalanceResponse {
|
|||||||
google.protobuf.Timestamp last_updated = 4;
|
google.protobuf.Timestamp last_updated = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEntryRequest is the request to retrieve a journal entry by reference.
|
||||||
message GetEntryRequest {
|
message GetEntryRequest {
|
||||||
string entry_ref = 1;
|
string entry_ref = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JournalEntryResponse represents a complete journal entry with all posting lines.
|
||||||
message JournalEntryResponse {
|
message JournalEntryResponse {
|
||||||
string entry_ref = 1;
|
string entry_ref = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -199,17 +247,20 @@ message JournalEntryResponse {
|
|||||||
repeated string ledger_account_refs = 9; // denormalized set for client-side filtering
|
repeated string ledger_account_refs = 9; // denormalized set for client-side filtering
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStatementRequest is the request to retrieve paginated journal entries for an account.
|
||||||
message GetStatementRequest {
|
message GetStatementRequest {
|
||||||
string ledger_account_ref = 1;
|
string ledger_account_ref = 1;
|
||||||
string cursor = 2; // opaque
|
string cursor = 2; // opaque
|
||||||
int32 limit = 3; // page size
|
int32 limit = 3; // page size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatementResponse is a paginated list of journal entries.
|
||||||
message StatementResponse {
|
message StatementResponse {
|
||||||
repeated JournalEntryResponse entries = 1;
|
repeated JournalEntryResponse entries = 1;
|
||||||
string next_cursor = 2;
|
string next_cursor = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAccountsRequest is the request to list ledger accounts with optional filters.
|
||||||
message ListAccountsRequest {
|
message ListAccountsRequest {
|
||||||
string organization_ref = 1;
|
string organization_ref = 1;
|
||||||
// Optional owner filter with 3-state semantics:
|
// Optional owner filter with 3-state semantics:
|
||||||
@@ -219,28 +270,33 @@ message ListAccountsRequest {
|
|||||||
google.protobuf.StringValue owner_ref_filter = 2;
|
google.protobuf.StringValue owner_ref_filter = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAccountsResponse is the response for ListAccounts.
|
||||||
message ListAccountsResponse {
|
message ListAccountsResponse {
|
||||||
repeated LedgerAccount accounts = 1;
|
repeated LedgerAccount accounts = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Account status mutations ----
|
// ---- Account status mutations ----
|
||||||
|
|
||||||
|
// BlockAccountRequest is the request to freeze (block) a ledger account.
|
||||||
message BlockAccountRequest {
|
message BlockAccountRequest {
|
||||||
string ledger_account_ref = 1;
|
string ledger_account_ref = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
AccountRole role = 3; // optional: assert account has this role before blocking
|
AccountRole role = 3; // optional: assert account has this role before blocking
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlockAccountResponse is the response for BlockAccount.
|
||||||
message BlockAccountResponse {
|
message BlockAccountResponse {
|
||||||
LedgerAccount account = 1;
|
LedgerAccount account = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnblockAccountRequest is the request to unfreeze (unblock) a ledger account.
|
||||||
message UnblockAccountRequest {
|
message UnblockAccountRequest {
|
||||||
string ledger_account_ref = 1;
|
string ledger_account_ref = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
AccountRole role = 3; // optional: assert account has this role before unblocking
|
AccountRole role = 3; // optional: assert account has this role before unblocking
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnblockAccountResponse is the response for UnblockAccount.
|
||||||
message UnblockAccountResponse {
|
message UnblockAccountResponse {
|
||||||
LedgerAccount account = 1;
|
LedgerAccount account = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
|||||||
|
|
||||||
import "operation_result.proto";
|
import "operation_result.proto";
|
||||||
|
|
||||||
|
// NotificationSentEvent is published after a notification has been delivered
|
||||||
|
// (or delivery has failed) to a user.
|
||||||
message NotificationSentEvent {
|
message NotificationSentEvent {
|
||||||
|
// user_id identifies the recipient.
|
||||||
string user_id = 1;
|
string user_id = 1;
|
||||||
|
// template_id is the notification template that was rendered.
|
||||||
string template_id = 2;
|
string template_id = 2;
|
||||||
|
// channel is the delivery channel (e.g. "email", "sms", "push").
|
||||||
string channel = 3;
|
string channel = 3;
|
||||||
|
// locale is the language/region used for rendering (e.g. "en", "ru").
|
||||||
string locale = 4;
|
string locale = 4;
|
||||||
|
// status reports whether the delivery succeeded.
|
||||||
OperationResult status = 5;
|
OperationResult status = 5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ syntax = "proto3";
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||||
|
|
||||||
|
// ObjectUpdatedEvent is a generic event published when any domain object is
|
||||||
|
// modified, carrying the object reference and the acting user.
|
||||||
message ObjectUpdatedEvent {
|
message ObjectUpdatedEvent {
|
||||||
|
// object_ref is the unique reference of the updated object.
|
||||||
string object_ref = 1;
|
string object_ref = 1;
|
||||||
|
// actor_account_ref identifies the account that performed the update.
|
||||||
string actor_account_ref = 2;
|
string actor_account_ref = 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ syntax = "proto3";
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||||
|
|
||||||
|
// OperationResult reports the success or failure of an asynchronous operation.
|
||||||
message OperationResult {
|
message OperationResult {
|
||||||
|
// is_successful is true when the operation completed without errors.
|
||||||
bool is_successful = 1;
|
bool is_successful = 1;
|
||||||
|
// error_description contains a human-readable error message when
|
||||||
|
// is_successful is false.
|
||||||
string error_description = 2;
|
string error_description = 2;
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ import "api/proto/common/money/v1/money.proto";
|
|||||||
import "api/proto/common/fx/v1/fx.proto";
|
import "api/proto/common/fx/v1/fx.proto";
|
||||||
import "api/proto/common/trace/v1/trace.proto";
|
import "api/proto/common/trace/v1/trace.proto";
|
||||||
|
|
||||||
|
// RateSnapshot holds a point-in-time rate observation from a provider.
|
||||||
message RateSnapshot {
|
message RateSnapshot {
|
||||||
common.fx.v1.CurrencyPair pair = 1;
|
common.fx.v1.CurrencyPair pair = 1;
|
||||||
common.money.v1.Decimal mid = 2;
|
common.money.v1.Decimal mid = 2;
|
||||||
@@ -21,6 +21,7 @@ message RateSnapshot {
|
|||||||
common.money.v1.Decimal spread_bps = 8;
|
common.money.v1.Decimal spread_bps = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestMeta carries caller identity and tracing context for oracle requests.
|
||||||
message RequestMeta {
|
message RequestMeta {
|
||||||
reserved 1, 4, 5;
|
reserved 1, 4, 5;
|
||||||
reserved "request_ref", "idempotency_key", "trace_ref";
|
reserved "request_ref", "idempotency_key", "trace_ref";
|
||||||
@@ -30,6 +31,7 @@ message RequestMeta {
|
|||||||
common.trace.v1.TraceContext trace = 6;
|
common.trace.v1.TraceContext trace = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResponseMeta carries tracing context for oracle responses.
|
||||||
message ResponseMeta {
|
message ResponseMeta {
|
||||||
reserved 1, 2;
|
reserved 1, 2;
|
||||||
reserved "request_ref", "trace_ref";
|
reserved "request_ref", "trace_ref";
|
||||||
@@ -37,6 +39,7 @@ message ResponseMeta {
|
|||||||
common.trace.v1.TraceContext trace = 3;
|
common.trace.v1.TraceContext trace = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quote represents a priced FX quote with an expiry window.
|
||||||
message Quote {
|
message Quote {
|
||||||
string quote_ref = 1;
|
string quote_ref = 1;
|
||||||
common.fx.v1.CurrencyPair pair = 2;
|
common.fx.v1.CurrencyPair pair = 2;
|
||||||
@@ -51,6 +54,7 @@ message Quote {
|
|||||||
google.protobuf.Timestamp priced_at = 11;
|
google.protobuf.Timestamp priced_at = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetQuoteRequest is the request to obtain an FX quote.
|
||||||
message GetQuoteRequest {
|
message GetQuoteRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
common.fx.v1.CurrencyPair pair = 2;
|
common.fx.v1.CurrencyPair pair = 2;
|
||||||
@@ -65,16 +69,19 @@ message GetQuoteRequest {
|
|||||||
int32 max_age_ms = 9;
|
int32 max_age_ms = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetQuoteResponse is the response for GetQuote.
|
||||||
message GetQuoteResponse {
|
message GetQuoteResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
Quote quote = 2;
|
Quote quote = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateQuoteRequest is the request to check whether a quote is still valid.
|
||||||
message ValidateQuoteRequest {
|
message ValidateQuoteRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
string quote_ref = 2;
|
string quote_ref = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateQuoteResponse is the response for ValidateQuote.
|
||||||
message ValidateQuoteResponse {
|
message ValidateQuoteResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
Quote quote = 2;
|
Quote quote = 2;
|
||||||
@@ -82,48 +89,61 @@ message ValidateQuoteResponse {
|
|||||||
string reason = 4;
|
string reason = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsumeQuoteRequest marks a quote as used, linking it to a ledger transaction.
|
||||||
message ConsumeQuoteRequest {
|
message ConsumeQuoteRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
string quote_ref = 2;
|
string quote_ref = 2;
|
||||||
string ledger_txn_ref = 3;
|
string ledger_txn_ref = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsumeQuoteResponse is the response for ConsumeQuote.
|
||||||
message ConsumeQuoteResponse {
|
message ConsumeQuoteResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
bool consumed = 2;
|
bool consumed = 2;
|
||||||
string reason = 3;
|
string reason = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LatestRateRequest is the request to fetch the most recent rate for a pair.
|
||||||
message LatestRateRequest {
|
message LatestRateRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
common.fx.v1.CurrencyPair pair = 2;
|
common.fx.v1.CurrencyPair pair = 2;
|
||||||
string provider = 3;
|
string provider = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LatestRateResponse is the response for LatestRate.
|
||||||
message LatestRateResponse {
|
message LatestRateResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
RateSnapshot rate = 2;
|
RateSnapshot rate = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPairsRequest is the request to list all supported currency pairs.
|
||||||
message ListPairsRequest {
|
message ListPairsRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PairMeta holds metadata for a supported currency pair.
|
||||||
message PairMeta {
|
message PairMeta {
|
||||||
common.fx.v1.CurrencyPair pair = 1;
|
common.fx.v1.CurrencyPair pair = 1;
|
||||||
common.money.v1.CurrencyMeta base_meta = 2;
|
common.money.v1.CurrencyMeta base_meta = 2;
|
||||||
common.money.v1.CurrencyMeta quote_meta = 3;
|
common.money.v1.CurrencyMeta quote_meta = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPairsResponse is the response for ListPairs.
|
||||||
message ListPairsResponse {
|
message ListPairsResponse {
|
||||||
ResponseMeta meta = 1;
|
ResponseMeta meta = 1;
|
||||||
repeated PairMeta pairs = 2;
|
repeated PairMeta pairs = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Oracle provides FX rate quoting, validation, and consumption.
|
||||||
service Oracle {
|
service Oracle {
|
||||||
|
// GetQuote returns a priced FX quote for a currency pair.
|
||||||
rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse);
|
rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse);
|
||||||
|
// ValidateQuote checks whether an existing quote is still valid.
|
||||||
rpc ValidateQuote(ValidateQuoteRequest) returns (ValidateQuoteResponse);
|
rpc ValidateQuote(ValidateQuoteRequest) returns (ValidateQuoteResponse);
|
||||||
|
// ConsumeQuote marks a quote as consumed and links it to a ledger transaction.
|
||||||
rpc ConsumeQuote(ConsumeQuoteRequest) returns (ConsumeQuoteResponse);
|
rpc ConsumeQuote(ConsumeQuoteRequest) returns (ConsumeQuoteResponse);
|
||||||
|
// LatestRate returns the most recent rate snapshot for a currency pair.
|
||||||
rpc LatestRate(LatestRateRequest) returns (LatestRateResponse);
|
rpc LatestRate(LatestRateRequest) returns (LatestRateResponse);
|
||||||
|
// ListPairs returns all supported currency pairs.
|
||||||
rpc ListPairs(ListPairsRequest) returns (ListPairsResponse);
|
rpc ListPairs(ListPairsRequest) returns (ListPairsResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ syntax = "proto3";
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||||
|
|
||||||
|
// PasswordResetEvent is published when a user requests a password reset.
|
||||||
message PasswordResetEvent {
|
message PasswordResetEvent {
|
||||||
|
// account_ref is the unique reference of the account requesting the reset.
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
|
// reset_token is the one-time token the user must present to set a new
|
||||||
|
// password.
|
||||||
string reset_token = 2;
|
string reset_token = 2;
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/endpoint/v1;endp
|
|||||||
import "api/proto/common/describable/v1/describable.proto";
|
import "api/proto/common/describable/v1/describable.proto";
|
||||||
import "api/proto/common/permission_bound/v1/pbound.proto";
|
import "api/proto/common/permission_bound/v1/pbound.proto";
|
||||||
|
|
||||||
|
// PaymentMethodType classifies the kind of payment instrument.
|
||||||
enum PaymentMethodType {
|
enum PaymentMethodType {
|
||||||
PAYMENT_METHOD_TYPE_UNSPECIFIED = 0;
|
PAYMENT_METHOD_TYPE_UNSPECIFIED = 0;
|
||||||
PAYMENT_METHOD_TYPE_IBAN = 1;
|
PAYMENT_METHOD_TYPE_IBAN = 1;
|
||||||
@@ -19,6 +20,7 @@ enum PaymentMethodType {
|
|||||||
PAYMENT_METHOD_TYPE_ACCOUNT = 8;
|
PAYMENT_METHOD_TYPE_ACCOUNT = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentMethod represents a stored payment instrument (card, IBAN, wallet, etc.).
|
||||||
message PaymentMethod {
|
message PaymentMethod {
|
||||||
common.describable.v1.Describable describable = 1;
|
common.describable.v1.Describable describable = 1;
|
||||||
string recipient_ref = 2;
|
string recipient_ref = 2;
|
||||||
@@ -27,6 +29,8 @@ message PaymentMethod {
|
|||||||
bool is_main = 5;
|
bool is_main = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentEndpoint resolves a payment destination by reference, inline method,
|
||||||
|
// or payee lookup.
|
||||||
message PaymentEndpoint {
|
message PaymentEndpoint {
|
||||||
oneof source {
|
oneof source {
|
||||||
string payment_method_ref = 1;
|
string payment_method_ref = 1;
|
||||||
@@ -35,6 +39,8 @@ message PaymentEndpoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentMethodRecord wraps a PaymentMethod with its permission and
|
||||||
|
// persistence metadata.
|
||||||
message PaymentMethodRecord {
|
message PaymentMethodRecord {
|
||||||
common.pbound.v1.PermissionBound permission_bound = 1;
|
common.pbound.v1.PermissionBound permission_bound = 1;
|
||||||
PaymentMethod payment_method = 2;
|
PaymentMethod payment_method = 2;
|
||||||
|
|||||||
@@ -7,25 +7,30 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/methods/v1;metho
|
|||||||
import "api/proto/common/pagination/v2/cursor.proto";
|
import "api/proto/common/pagination/v2/cursor.proto";
|
||||||
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
||||||
|
|
||||||
|
// CreatePaymentMethodRequest is the request to create a new payment method.
|
||||||
message CreatePaymentMethodRequest {
|
message CreatePaymentMethodRequest {
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
payments.endpoint.v1.PaymentMethod payment_method = 3;
|
payments.endpoint.v1.PaymentMethod payment_method = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatePaymentMethodResponse is the response for CreatePaymentMethod.
|
||||||
message CreatePaymentMethodResponse {
|
message CreatePaymentMethodResponse {
|
||||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPaymentMethodRequest is the request to retrieve a payment method.
|
||||||
message GetPaymentMethodRequest {
|
message GetPaymentMethodRequest {
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
string payment_method_ref = 2;
|
string payment_method_ref = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPaymentMethodResponse is the response for GetPaymentMethod.
|
||||||
message GetPaymentMethodResponse {
|
message GetPaymentMethodResponse {
|
||||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPaymentMethodPrivateRequest retrieves a payment method without permission checks.
|
||||||
message GetPaymentMethodPrivateRequest {
|
message GetPaymentMethodPrivateRequest {
|
||||||
string organization_ref = 1;
|
string organization_ref = 1;
|
||||||
oneof selector {
|
oneof selector {
|
||||||
@@ -35,33 +40,43 @@ message GetPaymentMethodPrivateRequest {
|
|||||||
PrivateEndpoint endpoint = 4;
|
PrivateEndpoint endpoint = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrivateEndpoint specifies which side of a payment method to retrieve.
|
||||||
enum PrivateEndpoint {
|
enum PrivateEndpoint {
|
||||||
|
// PRIVATE_ENDPOINT_UNSPECIFIED is the default zero value.
|
||||||
PRIVATE_ENDPOINT_UNSPECIFIED = 0;
|
PRIVATE_ENDPOINT_UNSPECIFIED = 0;
|
||||||
|
// PRIVATE_ENDPOINT_SOURCE retrieves the source endpoint.
|
||||||
PRIVATE_ENDPOINT_SOURCE = 1;
|
PRIVATE_ENDPOINT_SOURCE = 1;
|
||||||
|
// PRIVATE_ENDPOINT_DESTINATION retrieves the destination endpoint.
|
||||||
PRIVATE_ENDPOINT_DESTINATION = 2;
|
PRIVATE_ENDPOINT_DESTINATION = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPaymentMethodPrivateResponse is the response for GetPaymentMethodPrivate.
|
||||||
message GetPaymentMethodPrivateResponse {
|
message GetPaymentMethodPrivateResponse {
|
||||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePaymentMethodRequest is the request to update an existing payment method.
|
||||||
message UpdatePaymentMethodRequest {
|
message UpdatePaymentMethodRequest {
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 2;
|
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePaymentMethodResponse is the response for UpdatePaymentMethod.
|
||||||
message UpdatePaymentMethodResponse {
|
message UpdatePaymentMethodResponse {
|
||||||
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePaymentMethodRequest is the request to delete a payment method.
|
||||||
message DeletePaymentMethodRequest {
|
message DeletePaymentMethodRequest {
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
string payment_method_ref = 2;
|
string payment_method_ref = 2;
|
||||||
bool cascade = 3;
|
bool cascade = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePaymentMethodResponse is the response for DeletePaymentMethod.
|
||||||
message DeletePaymentMethodResponse {}
|
message DeletePaymentMethodResponse {}
|
||||||
|
|
||||||
|
// SetPaymentMethodArchivedRequest is the request to archive or unarchive a payment method.
|
||||||
message SetPaymentMethodArchivedRequest {
|
message SetPaymentMethodArchivedRequest {
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -70,8 +85,10 @@ message SetPaymentMethodArchivedRequest {
|
|||||||
bool cascade = 5;
|
bool cascade = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPaymentMethodArchivedResponse is the response for SetPaymentMethodArchived.
|
||||||
message SetPaymentMethodArchivedResponse {}
|
message SetPaymentMethodArchivedResponse {}
|
||||||
|
|
||||||
|
// ListPaymentMethodsRequest is the request to list payment methods with optional filters.
|
||||||
message ListPaymentMethodsRequest {
|
message ListPaymentMethodsRequest {
|
||||||
string account_ref = 1;
|
string account_ref = 1;
|
||||||
string organization_ref = 2;
|
string organization_ref = 2;
|
||||||
@@ -79,6 +96,7 @@ message ListPaymentMethodsRequest {
|
|||||||
common.pagination.v2.ViewCursor cursor = 4;
|
common.pagination.v2.ViewCursor cursor = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPaymentMethodsResponse is the response for ListPaymentMethods.
|
||||||
message ListPaymentMethodsResponse {
|
message ListPaymentMethodsResponse {
|
||||||
repeated payments.endpoint.v1.PaymentMethodRecord payment_methods = 1;
|
repeated payments.endpoint.v1.PaymentMethodRecord payment_methods = 1;
|
||||||
}
|
}
|
||||||
@@ -91,7 +109,7 @@ service PaymentMethodsService {
|
|||||||
rpc GetPaymentMethod(GetPaymentMethodRequest) returns (GetPaymentMethodResponse);
|
rpc GetPaymentMethod(GetPaymentMethodRequest) returns (GetPaymentMethodResponse);
|
||||||
// UpdatePaymentMethod updates an existing payment method.
|
// UpdatePaymentMethod updates an existing payment method.
|
||||||
rpc UpdatePaymentMethod(UpdatePaymentMethodRequest) returns (UpdatePaymentMethodResponse);
|
rpc UpdatePaymentMethod(UpdatePaymentMethodRequest) returns (UpdatePaymentMethodResponse);
|
||||||
// Delete exising payment method
|
// DeletePaymentMethod deletes an existing payment method.
|
||||||
rpc DeletePaymentMethod(DeletePaymentMethodRequest) returns (DeletePaymentMethodResponse);
|
rpc DeletePaymentMethod(DeletePaymentMethodRequest) returns (DeletePaymentMethodResponse);
|
||||||
// SetPaymentMethodArchived sets the archived status of a payment method.
|
// SetPaymentMethodArchived sets the archived status of a payment method.
|
||||||
rpc SetPaymentMethodArchived(SetPaymentMethodArchivedRequest) returns (SetPaymentMethodArchivedResponse);
|
rpc SetPaymentMethodArchived(SetPaymentMethodArchivedRequest) returns (SetPaymentMethodArchivedResponse);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import "api/proto/gateway/chain/v1/chain.proto";
|
|||||||
import "api/proto/gateway/mntx/v1/mntx.proto";
|
import "api/proto/gateway/mntx/v1/mntx.proto";
|
||||||
import "api/proto/payments/shared/v1/shared.proto";
|
import "api/proto/payments/shared/v1/shared.proto";
|
||||||
|
|
||||||
|
// InitiatePaymentsRequest triggers execution of all payment intents within
|
||||||
|
// a previously accepted quote.
|
||||||
message InitiatePaymentsRequest {
|
message InitiatePaymentsRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -17,10 +19,12 @@ message InitiatePaymentsRequest {
|
|||||||
map<string, string> metadata = 4;
|
map<string, string> metadata = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitiatePaymentsResponse returns the created payments.
|
||||||
message InitiatePaymentsResponse {
|
message InitiatePaymentsResponse {
|
||||||
repeated payments.shared.v1.Payment payments = 1;
|
repeated payments.shared.v1.Payment payments = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitiatePaymentRequest creates a single payment from a standalone intent.
|
||||||
message InitiatePaymentRequest {
|
message InitiatePaymentRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -29,19 +33,23 @@ message InitiatePaymentRequest {
|
|||||||
string quote_ref = 5;
|
string quote_ref = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitiatePaymentResponse returns the created payment.
|
||||||
message InitiatePaymentResponse {
|
message InitiatePaymentResponse {
|
||||||
payments.shared.v1.Payment payment = 1;
|
payments.shared.v1.Payment payment = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPaymentRequest fetches a payment by its reference.
|
||||||
message GetPaymentRequest {
|
message GetPaymentRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string payment_ref = 2;
|
string payment_ref = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPaymentResponse returns the requested payment.
|
||||||
message GetPaymentResponse {
|
message GetPaymentResponse {
|
||||||
payments.shared.v1.Payment payment = 1;
|
payments.shared.v1.Payment payment = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPaymentsRequest queries payments with optional state and endpoint filters.
|
||||||
message ListPaymentsRequest {
|
message ListPaymentsRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
repeated payments.shared.v1.PaymentState filter_states = 2;
|
repeated payments.shared.v1.PaymentState filter_states = 2;
|
||||||
@@ -51,48 +59,63 @@ message ListPaymentsRequest {
|
|||||||
string organization_ref = 6;
|
string organization_ref = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPaymentsResponse returns a page of matching payments.
|
||||||
message ListPaymentsResponse {
|
message ListPaymentsResponse {
|
||||||
repeated payments.shared.v1.Payment payments = 1;
|
repeated payments.shared.v1.Payment payments = 1;
|
||||||
common.pagination.v1.CursorPageResponse page = 2;
|
common.pagination.v1.CursorPageResponse page = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelPaymentRequest requests cancellation of a payment that has not yet
|
||||||
|
// been settled.
|
||||||
message CancelPaymentRequest {
|
message CancelPaymentRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string payment_ref = 2;
|
string payment_ref = 2;
|
||||||
string reason = 3;
|
string reason = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelPaymentResponse returns the updated payment after cancellation.
|
||||||
message CancelPaymentResponse {
|
message CancelPaymentResponse {
|
||||||
payments.shared.v1.Payment payment = 1;
|
payments.shared.v1.Payment payment = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessTransferUpdateRequest handles a blockchain transfer status change
|
||||||
|
// event from the chain gateway.
|
||||||
message ProcessTransferUpdateRequest {
|
message ProcessTransferUpdateRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
chain.gateway.v1.TransferStatusChangedEvent event = 2;
|
chain.gateway.v1.TransferStatusChangedEvent event = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessTransferUpdateResponse returns the payment after processing.
|
||||||
message ProcessTransferUpdateResponse {
|
message ProcessTransferUpdateResponse {
|
||||||
payments.shared.v1.Payment payment = 1;
|
payments.shared.v1.Payment payment = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessDepositObservedRequest handles a wallet deposit observation event
|
||||||
|
// from the chain gateway.
|
||||||
message ProcessDepositObservedRequest {
|
message ProcessDepositObservedRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
chain.gateway.v1.WalletDepositObservedEvent event = 2;
|
chain.gateway.v1.WalletDepositObservedEvent event = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessDepositObservedResponse returns the payment after processing.
|
||||||
message ProcessDepositObservedResponse {
|
message ProcessDepositObservedResponse {
|
||||||
payments.shared.v1.Payment payment = 1;
|
payments.shared.v1.Payment payment = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessCardPayoutUpdateRequest handles a card payout status change event
|
||||||
|
// from the card gateway.
|
||||||
message ProcessCardPayoutUpdateRequest {
|
message ProcessCardPayoutUpdateRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2;
|
mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessCardPayoutUpdateResponse returns the payment after processing.
|
||||||
message ProcessCardPayoutUpdateResponse {
|
message ProcessCardPayoutUpdateResponse {
|
||||||
payments.shared.v1.Payment payment = 1;
|
payments.shared.v1.Payment payment = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitiateConversionRequest creates an FX conversion payment between two
|
||||||
|
// ledger endpoints.
|
||||||
message InitiateConversionRequest {
|
message InitiateConversionRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -103,18 +126,30 @@ message InitiateConversionRequest {
|
|||||||
map<string, string> metadata = 7;
|
map<string, string> metadata = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitiateConversionResponse returns the created conversion payment.
|
||||||
message InitiateConversionResponse {
|
message InitiateConversionResponse {
|
||||||
payments.shared.v1.Payment conversion = 1;
|
payments.shared.v1.Payment conversion = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentExecutionService orchestrates payment lifecycle operations across
|
||||||
|
// ledger, blockchain, card, and FX rails.
|
||||||
service PaymentExecutionService {
|
service PaymentExecutionService {
|
||||||
|
// InitiatePayments executes all intents within a quote.
|
||||||
rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse);
|
rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse);
|
||||||
|
// InitiatePayment creates and executes a single payment.
|
||||||
rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse);
|
rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse);
|
||||||
|
// CancelPayment cancels a pending payment.
|
||||||
rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse);
|
rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse);
|
||||||
|
// GetPayment retrieves a payment by reference.
|
||||||
rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse);
|
rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse);
|
||||||
|
// ListPayments queries payments with filters and pagination.
|
||||||
rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse);
|
rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse);
|
||||||
|
// InitiateConversion creates an FX conversion payment.
|
||||||
rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse);
|
rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse);
|
||||||
|
// ProcessTransferUpdate handles blockchain transfer status callbacks.
|
||||||
rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse);
|
rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse);
|
||||||
|
// ProcessDepositObserved handles deposit observation callbacks.
|
||||||
rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse);
|
rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse);
|
||||||
|
// ProcessCardPayoutUpdate handles card payout status callbacks.
|
||||||
rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse);
|
rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
import "api/proto/payments/transfer/v1/transfer.proto";
|
||||||
|
|
||||||
|
// PaymentIntent describes the full intent for an external payment,
|
||||||
// -------------------------
|
// wrapping a transfer with payer/payee identity and purpose.
|
||||||
// External payment semantics
|
|
||||||
// -------------------------
|
|
||||||
message PaymentIntent {
|
message PaymentIntent {
|
||||||
|
// transfer is the underlying value movement.
|
||||||
payments.transfer.v1.TransferIntent transfer = 1;
|
payments.transfer.v1.TransferIntent transfer = 1;
|
||||||
|
// payer_ref identifies the entity funding the payment.
|
||||||
string payer_ref = 2;
|
string payer_ref = 2;
|
||||||
|
// payee_ref identifies the payment beneficiary.
|
||||||
string payee_ref = 3;
|
string payee_ref = 3;
|
||||||
|
// purpose is a human-readable description of the payment reason.
|
||||||
string purpose = 4;
|
string purpose = 4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v1;quo
|
|||||||
|
|
||||||
import "api/proto/payments/shared/v1/shared.proto";
|
import "api/proto/payments/shared/v1/shared.proto";
|
||||||
|
|
||||||
|
// QuotePaymentRequest is the request to quote a single payment.
|
||||||
message QuotePaymentRequest {
|
message QuotePaymentRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -14,6 +14,7 @@ message QuotePaymentRequest {
|
|||||||
bool preview_only = 4;
|
bool preview_only = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotePaymentResponse is the response for QuotePayment.
|
||||||
message QuotePaymentResponse {
|
message QuotePaymentResponse {
|
||||||
payments.shared.v1.PaymentQuote quote = 1;
|
payments.shared.v1.PaymentQuote quote = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -21,6 +22,7 @@ message QuotePaymentResponse {
|
|||||||
string execution_note = 3;
|
string execution_note = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotePaymentsRequest is the request to quote multiple payments in a batch.
|
||||||
message QuotePaymentsRequest {
|
message QuotePaymentsRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -28,6 +30,7 @@ message QuotePaymentsRequest {
|
|||||||
bool preview_only = 4;
|
bool preview_only = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotePaymentsResponse is the response for QuotePayments.
|
||||||
message QuotePaymentsResponse {
|
message QuotePaymentsResponse {
|
||||||
string quote_ref = 1;
|
string quote_ref = 1;
|
||||||
payments.shared.v1.PaymentQuoteAggregate aggregate = 2;
|
payments.shared.v1.PaymentQuoteAggregate aggregate = 2;
|
||||||
@@ -35,6 +38,7 @@ message QuotePaymentsResponse {
|
|||||||
string idempotency_key = 4;
|
string idempotency_key = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotationService provides payment quoting capabilities.
|
||||||
service QuotationService {
|
service QuotationService {
|
||||||
// QuotePayment returns a quote for a single payment request.
|
// QuotePayment returns a quote for a single payment request.
|
||||||
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
|
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import "api/proto/common/payment/v1/settlement.proto";
|
|||||||
import "api/proto/billing/fees/v1/fees.proto";
|
import "api/proto/billing/fees/v1/fees.proto";
|
||||||
import "api/proto/oracle/v1/oracle.proto";
|
import "api/proto/oracle/v1/oracle.proto";
|
||||||
|
|
||||||
|
// QuoteState tracks the lifecycle of a payment quote.
|
||||||
enum QuoteState {
|
enum QuoteState {
|
||||||
QUOTE_STATE_UNSPECIFIED = 0;
|
QUOTE_STATE_UNSPECIFIED = 0;
|
||||||
QUOTE_STATE_INDICATIVE = 1;
|
QUOTE_STATE_INDICATIVE = 1;
|
||||||
@@ -20,6 +21,7 @@ enum QuoteState {
|
|||||||
QUOTE_STATE_EXPIRED = 4;
|
QUOTE_STATE_EXPIRED = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuoteBlockReason explains why a quote cannot be executed.
|
||||||
enum QuoteBlockReason {
|
enum QuoteBlockReason {
|
||||||
QUOTE_BLOCK_REASON_UNSPECIFIED = 0;
|
QUOTE_BLOCK_REASON_UNSPECIFIED = 0;
|
||||||
QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1;
|
QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE = 1;
|
||||||
@@ -31,6 +33,7 @@ enum QuoteBlockReason {
|
|||||||
QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE = 7;
|
QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuoteExecutionReadiness indicates how readily a quote can be executed.
|
||||||
enum QuoteExecutionReadiness {
|
enum QuoteExecutionReadiness {
|
||||||
QUOTE_EXECUTION_READINESS_UNSPECIFIED = 0;
|
QUOTE_EXECUTION_READINESS_UNSPECIFIED = 0;
|
||||||
QUOTE_EXECUTION_READINESS_LIQUIDITY_READY = 1;
|
QUOTE_EXECUTION_READINESS_LIQUIDITY_READY = 1;
|
||||||
@@ -38,6 +41,7 @@ enum QuoteExecutionReadiness {
|
|||||||
QUOTE_EXECUTION_READINESS_INDICATIVE = 3;
|
QUOTE_EXECUTION_READINESS_INDICATIVE = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RouteHopRole classifies a hop's position in the payment route.
|
||||||
enum RouteHopRole {
|
enum RouteHopRole {
|
||||||
ROUTE_HOP_ROLE_UNSPECIFIED = 0;
|
ROUTE_HOP_ROLE_UNSPECIFIED = 0;
|
||||||
ROUTE_HOP_ROLE_SOURCE = 1;
|
ROUTE_HOP_ROLE_SOURCE = 1;
|
||||||
@@ -45,12 +49,14 @@ enum RouteHopRole {
|
|||||||
ROUTE_HOP_ROLE_DESTINATION = 3;
|
ROUTE_HOP_ROLE_DESTINATION = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeeTreatment determines how fees are applied to the transfer amount.
|
||||||
enum FeeTreatment {
|
enum FeeTreatment {
|
||||||
FEE_TREATMENT_UNSPECIFIED = 0;
|
FEE_TREATMENT_UNSPECIFIED = 0;
|
||||||
FEE_TREATMENT_ADD_TO_SOURCE = 1;
|
FEE_TREATMENT_ADD_TO_SOURCE = 1;
|
||||||
FEE_TREATMENT_DEDUCT_FROM_DESTINATION = 2;
|
FEE_TREATMENT_DEDUCT_FROM_DESTINATION = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RouteHop represents a single step in the payment route topology.
|
||||||
message RouteHop {
|
message RouteHop {
|
||||||
uint32 index = 1;
|
uint32 index = 1;
|
||||||
string rail = 2;
|
string rail = 2;
|
||||||
@@ -60,6 +66,7 @@ message RouteHop {
|
|||||||
RouteHopRole role = 6;
|
RouteHopRole role = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RouteSettlement describes the settlement asset and model for a route.
|
||||||
message RouteSettlement {
|
message RouteSettlement {
|
||||||
common.payment.v1.ChainAsset asset = 1;
|
common.payment.v1.ChainAsset asset = 1;
|
||||||
string model = 2;
|
string model = 2;
|
||||||
@@ -91,6 +98,7 @@ message ExecutionConditions {
|
|||||||
repeated string assumptions = 7;
|
repeated string assumptions = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentQuote is a priced, time-bound quote for a single payment intent.
|
||||||
message PaymentQuote {
|
message PaymentQuote {
|
||||||
common.storable.v1.Storable storable = 1;
|
common.storable.v1.Storable storable = 1;
|
||||||
QuoteState state = 2;
|
QuoteState state = 2;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "api/proto/common/payment/v1/settlement.proto";
|
|||||||
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
||||||
import "api/proto/payments/quotation/v2/interface.proto";
|
import "api/proto/payments/quotation/v2/interface.proto";
|
||||||
|
|
||||||
|
// QuoteIntent describes the intent behind a v2 quote request.
|
||||||
message QuoteIntent {
|
message QuoteIntent {
|
||||||
payments.endpoint.v1.PaymentEndpoint source = 1;
|
payments.endpoint.v1.PaymentEndpoint source = 1;
|
||||||
payments.endpoint.v1.PaymentEndpoint destination = 2;
|
payments.endpoint.v1.PaymentEndpoint destination = 2;
|
||||||
@@ -20,6 +21,7 @@ message QuoteIntent {
|
|||||||
string comment = 7;
|
string comment = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotePaymentRequest is the request to quote a single v2 payment.
|
||||||
message QuotePaymentRequest {
|
message QuotePaymentRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -28,11 +30,13 @@ message QuotePaymentRequest {
|
|||||||
string initiator_ref = 5;
|
string initiator_ref = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotePaymentResponse is the response for QuotePayment.
|
||||||
message QuotePaymentResponse {
|
message QuotePaymentResponse {
|
||||||
payments.quotation.v2.PaymentQuote quote = 1;
|
payments.quotation.v2.PaymentQuote quote = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotePaymentsRequest is the request to quote multiple v2 payments in a batch.
|
||||||
message QuotePaymentsRequest {
|
message QuotePaymentsRequest {
|
||||||
payments.shared.v1.RequestMeta meta = 1;
|
payments.shared.v1.RequestMeta meta = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
@@ -41,13 +45,14 @@ message QuotePaymentsRequest {
|
|||||||
string initiator_ref = 5;
|
string initiator_ref = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotePaymentsResponse is the response for QuotePayments.
|
||||||
message QuotePaymentsResponse {
|
message QuotePaymentsResponse {
|
||||||
string quote_ref = 1;
|
string quote_ref = 1;
|
||||||
repeated payments.quotation.v2.PaymentQuote quotes = 3;
|
repeated payments.quotation.v2.PaymentQuote quotes = 3;
|
||||||
string idempotency_key = 4;
|
string idempotency_key = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quotation service interface
|
// QuotationService provides v2 payment quoting capabilities.
|
||||||
service QuotationService {
|
service QuotationService {
|
||||||
// QuotePayment returns a quote for a single payment request.
|
// QuotePayment returns a quote for a single payment request.
|
||||||
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
|
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import "api/proto/billing/fees/v1/fees.proto";
|
|||||||
import "api/proto/gateway/chain/v1/chain.proto";
|
import "api/proto/gateway/chain/v1/chain.proto";
|
||||||
import "api/proto/oracle/v1/oracle.proto";
|
import "api/proto/oracle/v1/oracle.proto";
|
||||||
|
|
||||||
|
// PaymentKind classifies the type of payment operation.
|
||||||
enum PaymentKind {
|
enum PaymentKind {
|
||||||
PAYMENT_KIND_UNSPECIFIED = 0;
|
PAYMENT_KIND_UNSPECIFIED = 0;
|
||||||
PAYMENT_KIND_PAYOUT = 1;
|
PAYMENT_KIND_PAYOUT = 1;
|
||||||
@@ -21,6 +22,7 @@ enum PaymentKind {
|
|||||||
PAYMENT_KIND_FX_CONVERSION = 3;
|
PAYMENT_KIND_FX_CONVERSION = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentState tracks the lifecycle of a payment.
|
||||||
enum PaymentState {
|
enum PaymentState {
|
||||||
PAYMENT_STATE_UNSPECIFIED = 0;
|
PAYMENT_STATE_UNSPECIFIED = 0;
|
||||||
PAYMENT_STATE_ACCEPTED = 1;
|
PAYMENT_STATE_ACCEPTED = 1;
|
||||||
@@ -31,6 +33,7 @@ enum PaymentState {
|
|||||||
PAYMENT_STATE_CANCELLED = 6;
|
PAYMENT_STATE_CANCELLED = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentFailureCode categorises the reason for a payment failure.
|
||||||
enum PaymentFailureCode {
|
enum PaymentFailureCode {
|
||||||
FAILURE_UNSPECIFIED = 0;
|
FAILURE_UNSPECIFIED = 0;
|
||||||
FAILURE_BALANCE = 1;
|
FAILURE_BALANCE = 1;
|
||||||
@@ -41,21 +44,26 @@ enum PaymentFailureCode {
|
|||||||
FAILURE_POLICY = 6;
|
FAILURE_POLICY = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestMeta carries organisation context and tracing information for
|
||||||
|
// every payment service request.
|
||||||
message RequestMeta {
|
message RequestMeta {
|
||||||
string organization_ref = 1;
|
string organization_ref = 1;
|
||||||
common.trace.v1.TraceContext trace = 2;
|
common.trace.v1.TraceContext trace = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LedgerEndpoint identifies a source or destination on the internal ledger.
|
||||||
message LedgerEndpoint {
|
message LedgerEndpoint {
|
||||||
string ledger_account_ref = 1;
|
string ledger_account_ref = 1;
|
||||||
string contra_ledger_account_ref = 2;
|
string contra_ledger_account_ref = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManagedWalletEndpoint identifies a platform-managed blockchain wallet.
|
||||||
message ManagedWalletEndpoint {
|
message ManagedWalletEndpoint {
|
||||||
string managed_wallet_ref = 1;
|
string managed_wallet_ref = 1;
|
||||||
chain.gateway.v1.Asset asset = 2;
|
chain.gateway.v1.Asset asset = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExternalChainEndpoint identifies an external blockchain address.
|
||||||
message ExternalChainEndpoint {
|
message ExternalChainEndpoint {
|
||||||
chain.gateway.v1.Asset asset = 1;
|
chain.gateway.v1.Asset asset = 1;
|
||||||
string address = 2;
|
string address = 2;
|
||||||
@@ -76,6 +84,7 @@ message CardEndpoint {
|
|||||||
string masked_pan = 8;
|
string masked_pan = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentEndpoint is a polymorphic endpoint that can target any supported rail.
|
||||||
message PaymentEndpoint {
|
message PaymentEndpoint {
|
||||||
oneof endpoint {
|
oneof endpoint {
|
||||||
LedgerEndpoint ledger = 1;
|
LedgerEndpoint ledger = 1;
|
||||||
@@ -87,6 +96,7 @@ message PaymentEndpoint {
|
|||||||
string instance_id = 11;
|
string instance_id = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FXIntent describes the foreign-exchange requirements for a payment.
|
||||||
message FXIntent {
|
message FXIntent {
|
||||||
common.fx.v1.CurrencyPair pair = 1;
|
common.fx.v1.CurrencyPair pair = 1;
|
||||||
common.fx.v1.Side side = 2;
|
common.fx.v1.Side side = 2;
|
||||||
@@ -96,6 +106,8 @@ message FXIntent {
|
|||||||
int32 max_age_ms = 6;
|
int32 max_age_ms = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentIntent fully describes a payment to be executed, including source,
|
||||||
|
// destination, amount, FX, fee policy, and settlement preferences.
|
||||||
message PaymentIntent {
|
message PaymentIntent {
|
||||||
PaymentKind kind = 1;
|
PaymentKind kind = 1;
|
||||||
PaymentEndpoint source = 2;
|
PaymentEndpoint source = 2;
|
||||||
@@ -111,6 +123,8 @@ message PaymentIntent {
|
|||||||
string ref = 12;
|
string ref = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customer holds payer identity and address details for compliance and
|
||||||
|
// routing purposes.
|
||||||
message Customer {
|
message Customer {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string first_name = 2;
|
string first_name = 2;
|
||||||
@@ -124,6 +138,8 @@ message Customer {
|
|||||||
string address = 10;
|
string address = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentQuote captures the pricing snapshot for a payment including
|
||||||
|
// debit amount, expected settlement, fees, and FX details.
|
||||||
message PaymentQuote {
|
message PaymentQuote {
|
||||||
common.money.v1.Money debit_amount = 1;
|
common.money.v1.Money debit_amount = 1;
|
||||||
common.money.v1.Money expected_settlement_amount = 2;
|
common.money.v1.Money expected_settlement_amount = 2;
|
||||||
@@ -136,6 +152,7 @@ message PaymentQuote {
|
|||||||
common.money.v1.Money debit_settlement_amount = 9;
|
common.money.v1.Money debit_settlement_amount = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentQuoteAggregate summarises totals across multiple payment quotes.
|
||||||
message PaymentQuoteAggregate {
|
message PaymentQuoteAggregate {
|
||||||
repeated common.money.v1.Money debit_amounts = 1;
|
repeated common.money.v1.Money debit_amounts = 1;
|
||||||
repeated common.money.v1.Money expected_settlement_amounts = 2;
|
repeated common.money.v1.Money expected_settlement_amounts = 2;
|
||||||
@@ -143,6 +160,8 @@ message PaymentQuoteAggregate {
|
|||||||
repeated common.money.v1.Money network_fee_totals = 4;
|
repeated common.money.v1.Money network_fee_totals = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecutionRefs collects cross-service references created during payment
|
||||||
|
// execution (ledger entries, chain transfers, card payouts).
|
||||||
message ExecutionRefs {
|
message ExecutionRefs {
|
||||||
string debit_entry_ref = 1;
|
string debit_entry_ref = 1;
|
||||||
string credit_entry_ref = 2;
|
string credit_entry_ref = 2;
|
||||||
@@ -152,6 +171,7 @@ message ExecutionRefs {
|
|||||||
string fee_transfer_ref = 6;
|
string fee_transfer_ref = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecutionStep describes a single operational step in the legacy execution plan.
|
||||||
message ExecutionStep {
|
message ExecutionStep {
|
||||||
string code = 1;
|
string code = 1;
|
||||||
string description = 2;
|
string description = 2;
|
||||||
@@ -164,11 +184,13 @@ message ExecutionStep {
|
|||||||
string operation_ref = 9;
|
string operation_ref = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecutionPlan is the legacy ordered list of steps for fulfilling a payment.
|
||||||
message ExecutionPlan {
|
message ExecutionPlan {
|
||||||
repeated ExecutionStep steps = 1;
|
repeated ExecutionStep steps = 1;
|
||||||
common.money.v1.Money total_network_fee = 2;
|
common.money.v1.Money total_network_fee = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentStep is a single rail-level operation within a PaymentPlan.
|
||||||
message PaymentStep {
|
message PaymentStep {
|
||||||
common.gateway.v1.Rail rail = 1;
|
common.gateway.v1.Rail rail = 1;
|
||||||
string gateway_id = 2; // required for external rails
|
string gateway_id = 2; // required for external rails
|
||||||
@@ -181,6 +203,8 @@ message PaymentStep {
|
|||||||
repeated string commit_after = 9;
|
repeated string commit_after = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentPlan is the orchestrated sequence of rail-level steps that fulfil
|
||||||
|
// a payment, including FX and fee lines.
|
||||||
message PaymentPlan {
|
message PaymentPlan {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
repeated PaymentStep steps = 2;
|
repeated PaymentStep steps = 2;
|
||||||
@@ -202,6 +226,8 @@ message CardPayout {
|
|||||||
string gateway_reference = 8;
|
string gateway_reference = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payment is the top-level aggregate representing a payment throughout its
|
||||||
|
// lifecycle, from initiation through settlement or failure.
|
||||||
message Payment {
|
message Payment {
|
||||||
string payment_ref = 1;
|
string payment_ref = 1;
|
||||||
string idempotency_key = 2;
|
string idempotency_key = 2;
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/transfer/v1;tran
|
|||||||
import "api/proto/common/money/v1/money.proto";
|
import "api/proto/common/money/v1/money.proto";
|
||||||
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
import "api/proto/payments/endpoint/v1/endpoint.proto";
|
||||||
|
|
||||||
|
// TransferIntent describes a value movement between two payment endpoints.
|
||||||
// -------------------------
|
|
||||||
// Base value movement
|
|
||||||
// -------------------------
|
|
||||||
message TransferIntent {
|
message TransferIntent {
|
||||||
|
// source is the originating payment endpoint.
|
||||||
payments.endpoint.v1.PaymentEndpoint source = 1;
|
payments.endpoint.v1.PaymentEndpoint source = 1;
|
||||||
|
// destination is the receiving payment endpoint.
|
||||||
payments.endpoint.v1.PaymentEndpoint destination = 2;
|
payments.endpoint.v1.PaymentEndpoint destination = 2;
|
||||||
|
// amount is the monetary value to transfer.
|
||||||
common.money.v1.Money amount = 3;
|
common.money.v1.Money amount = 3;
|
||||||
|
// comment is an optional human-readable note for the transfer.
|
||||||
string comment = 4;
|
string comment = 4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ syntax = "proto3";
|
|||||||
|
|
||||||
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
|
||||||
|
|
||||||
|
// SiteRequestEvent is published when a visitor submits a request through the
|
||||||
|
// public website (demo, contact, or callback).
|
||||||
message SiteRequestEvent {
|
message SiteRequestEvent {
|
||||||
|
// RequestType classifies the kind of site request.
|
||||||
enum RequestType {
|
enum RequestType {
|
||||||
REQUEST_TYPE_UNSPECIFIED = 0;
|
REQUEST_TYPE_UNSPECIFIED = 0;
|
||||||
REQUEST_TYPE_DEMO = 1;
|
REQUEST_TYPE_DEMO = 1;
|
||||||
@@ -10,15 +13,20 @@ message SiteRequestEvent {
|
|||||||
REQUEST_TYPE_CALL = 3;
|
REQUEST_TYPE_CALL = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// type identifies which kind of request was submitted.
|
||||||
RequestType type = 1;
|
RequestType type = 1;
|
||||||
|
|
||||||
oneof payload {
|
oneof payload {
|
||||||
|
// demo is the payload for a product demo request.
|
||||||
SiteDemoRequest demo = 2;
|
SiteDemoRequest demo = 2;
|
||||||
|
// contact is the payload for a general contact inquiry.
|
||||||
SiteContactRequest contact = 3;
|
SiteContactRequest contact = 3;
|
||||||
|
// call is the payload for a callback request.
|
||||||
SiteCallRequest call = 4;
|
SiteCallRequest call = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteDemoRequest carries details for a product demo request.
|
||||||
message SiteDemoRequest {
|
message SiteDemoRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string organization_name = 2;
|
string organization_name = 2;
|
||||||
@@ -28,6 +36,7 @@ message SiteDemoRequest {
|
|||||||
string comment = 6;
|
string comment = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteContactRequest carries details for a general contact inquiry.
|
||||||
message SiteContactRequest {
|
message SiteContactRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string email = 2;
|
string email = 2;
|
||||||
@@ -37,6 +46,7 @@ message SiteContactRequest {
|
|||||||
string message = 6;
|
string message = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteCallRequest carries details for a callback request.
|
||||||
message SiteCallRequest {
|
message SiteCallRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string phone = 2;
|
string phone = 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user