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