new payment methods service

This commit is contained in:
Stephan D
2026-02-12 21:10:33 +01:00
parent b80dca0ce9
commit a862e27087
106 changed files with 3262 additions and 414 deletions

View File

@@ -0,0 +1,36 @@
package methods
import (
"context"
"github.com/tech/sendico/pkg/merrors"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
)
func (s *Service) SetPaymentMethodArchived(ctx context.Context, req *methodsv1.SetPaymentMethodArchivedRequest) (*methodsv1.SetPaymentMethodArchivedResponse, error) {
if req == nil {
return autoError[methodsv1.SetPaymentMethodArchivedResponse](ctx, s.logger, merrors.InvalidArgument("request is required"))
}
if s.pmstore == nil {
return autoError[methodsv1.SetPaymentMethodArchivedResponse](ctx, s.logger, errStoreUnavailable)
}
accountRef, err := parseObjectID(req.GetAccountRef(), "account_ref")
if err != nil {
return autoError[methodsv1.SetPaymentMethodArchivedResponse](ctx, s.logger, err)
}
organizationRef, err := parseObjectID(req.GetOrganizationRef(), "organization_ref")
if err != nil {
return autoError[methodsv1.SetPaymentMethodArchivedResponse](ctx, s.logger, err)
}
methodRef, err := parseObjectID(req.GetPaymentMethodRef(), "payment_method_ref")
if err != nil {
return autoError[methodsv1.SetPaymentMethodArchivedResponse](ctx, s.logger, err)
}
if err := s.pmstore.SetArchived(ctx, accountRef, organizationRef, methodRef, req.GetArchived(), req.GetCascade()); err != nil {
return autoError[methodsv1.SetPaymentMethodArchivedResponse](ctx, s.logger, err)
}
return &methodsv1.SetPaymentMethodArchivedResponse{}, nil
}

View File

@@ -0,0 +1,41 @@
package methods
import (
"context"
"github.com/tech/sendico/pkg/merrors"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
)
func (s *Service) CreatePaymentMethod(ctx context.Context, req *methodsv1.CreatePaymentMethodRequest) (*methodsv1.CreatePaymentMethodResponse, error) {
if req == nil {
return autoError[methodsv1.CreatePaymentMethodResponse](ctx, s.logger, merrors.InvalidArgument("request is required"))
}
if s.pmstore == nil {
return autoError[methodsv1.CreatePaymentMethodResponse](ctx, s.logger, errStoreUnavailable)
}
accountRef, err := parseObjectID(req.GetAccountRef(), "account_ref")
if err != nil {
return autoError[methodsv1.CreatePaymentMethodResponse](ctx, s.logger, err)
}
organizationRef, err := parseObjectID(req.GetOrganizationRef(), "organization_ref")
if err != nil {
return autoError[methodsv1.CreatePaymentMethodResponse](ctx, s.logger, err)
}
pm, err := decodePaymentMethod(req.GetPaymentMethodJson())
if err != nil {
return autoError[methodsv1.CreatePaymentMethodResponse](ctx, s.logger, err)
}
if err := s.pmstore.Create(ctx, accountRef, organizationRef, pm); err != nil {
return autoError[methodsv1.CreatePaymentMethodResponse](ctx, s.logger, err)
}
payload, err := encodePaymentMethod(pm)
if err != nil {
return autoError[methodsv1.CreatePaymentMethodResponse](ctx, s.logger, err)
}
return &methodsv1.CreatePaymentMethodResponse{PaymentMethodJson: payload}, nil
}

View File

@@ -0,0 +1,37 @@
package methods
import (
"context"
"github.com/tech/sendico/pkg/merrors"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
)
func (s *Service) DeletePaymentMethod(ctx context.Context, req *methodsv1.DeletePaymentMethodRequest) (*methodsv1.DeletePaymentMethodResponse, error) {
if req == nil {
return autoError[methodsv1.DeletePaymentMethodResponse](ctx, s.logger, merrors.InvalidArgument("request is required"))
}
if s.pmstore == nil {
return autoError[methodsv1.DeletePaymentMethodResponse](ctx, s.logger, errStoreUnavailable)
}
accountRef, err := parseObjectID(req.GetAccountRef(), "account_ref")
if err != nil {
return autoError[methodsv1.DeletePaymentMethodResponse](ctx, s.logger, err)
}
methodRef, err := parseObjectID(req.GetPaymentMethodRef(), "payment_method_ref")
if err != nil {
return autoError[methodsv1.DeletePaymentMethodResponse](ctx, s.logger, err)
}
if req.GetCascade() {
err = s.pmstore.DeleteCascade(ctx, accountRef, methodRef)
} else {
err = s.pmstore.Delete(ctx, accountRef, methodRef)
}
if err != nil {
return autoError[methodsv1.DeletePaymentMethodResponse](ctx, s.logger, err)
}
return &methodsv1.DeletePaymentMethodResponse{}, nil
}

View File

@@ -0,0 +1,38 @@
package methods
import (
"context"
"github.com/tech/sendico/pkg/merrors"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
)
func (s *Service) GetPaymentMethod(ctx context.Context, req *methodsv1.GetPaymentMethodRequest) (*methodsv1.GetPaymentMethodResponse, error) {
if req == nil {
return autoError[methodsv1.GetPaymentMethodResponse](ctx, s.logger, merrors.InvalidArgument("request is required"))
}
if s.pmstore == nil {
return autoError[methodsv1.GetPaymentMethodResponse](ctx, s.logger, errStoreUnavailable)
}
accountRef, err := parseObjectID(req.GetAccountRef(), "account_ref")
if err != nil {
return autoError[methodsv1.GetPaymentMethodResponse](ctx, s.logger, err)
}
methodRef, err := parseObjectID(req.GetPaymentMethodRef(), "payment_method_ref")
if err != nil {
return autoError[methodsv1.GetPaymentMethodResponse](ctx, s.logger, err)
}
pm, err := s.pmstore.Get(ctx, accountRef, methodRef)
if err != nil {
return autoError[methodsv1.GetPaymentMethodResponse](ctx, s.logger, err)
}
payload, err := encodePaymentMethod(pm)
if err != nil {
return autoError[methodsv1.GetPaymentMethodResponse](ctx, s.logger, err)
}
return &methodsv1.GetPaymentMethodResponse{PaymentMethodJson: payload}, nil
}

View File

@@ -0,0 +1,48 @@
package methods
import (
"context"
"github.com/tech/sendico/pkg/merrors"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
)
func (s *Service) ListPaymentMethods(ctx context.Context, req *methodsv1.ListPaymentMethodsRequest) (*methodsv1.ListPaymentMethodsResponse, error) {
if req == nil {
return autoError[methodsv1.ListPaymentMethodsResponse](ctx, s.logger, merrors.InvalidArgument("request is required"))
}
if s.pmstore == nil {
return autoError[methodsv1.ListPaymentMethodsResponse](ctx, s.logger, errStoreUnavailable)
}
accountRef, err := parseObjectID(req.GetAccountRef(), "account_ref")
if err != nil {
return autoError[methodsv1.ListPaymentMethodsResponse](ctx, s.logger, err)
}
organizationRef, err := parseObjectID(req.GetOrganizationRef(), "organization_ref")
if err != nil {
return autoError[methodsv1.ListPaymentMethodsResponse](ctx, s.logger, err)
}
recipientRef, err := parseObjectID(req.GetRecipientRef(), "recipient_ref")
if err != nil {
return autoError[methodsv1.ListPaymentMethodsResponse](ctx, s.logger, err)
}
items, err := s.pmstore.List(ctx, accountRef, organizationRef, recipientRef, toModelCursor(req.GetCursor()))
if err != nil {
return autoError[methodsv1.ListPaymentMethodsResponse](ctx, s.logger, err)
}
result := make([][]byte, 0, len(items))
for i := range items {
payload, err := encodePaymentMethod(&items[i])
if err != nil {
return autoError[methodsv1.ListPaymentMethodsResponse](ctx, s.logger, err)
}
result = append(result, payload)
}
return &methodsv1.ListPaymentMethodsResponse{
PaymentMethodsJson: result,
}, nil
}

View File

@@ -0,0 +1,87 @@
package methods
import (
"context"
cons "github.com/tech/sendico/pkg/messaging/consumer"
objectnotifications "github.com/tech/sendico/pkg/messaging/notifications/object"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (s *Service) startRecipientConsumers() {
if s == nil || s.recipientBroker == nil {
s.logger.Warn("Missing broker. Recipient cascade consumers have NOT started")
return
}
s.consumeRecipientProcessor(
objectnotifications.NewObjectChangedMessageProcessor(s.logger, mservice.Recipients, nm.NAArchived, s.onRecipientNotification),
)
s.consumeRecipientProcessor(
objectnotifications.NewObjectChangedMessageProcessor(s.logger, mservice.Recipients, nm.NADeleted, s.onRecipientNotification),
)
s.logger.Info("Recipient cascade consumers started")
}
func (s *Service) consumeRecipientProcessor(processor np.EnvelopeProcessor) {
consumer, err := cons.NewConsumer(s.logger, s.recipientBroker, processor.GetSubject())
if err != nil {
s.logger.Warn("Failed to create recipient consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
return
}
s.recipientConsumers = append(s.recipientConsumers, consumer)
go func() {
if err := consumer.ConsumeMessages(processor.Process); err != nil {
s.logger.Warn("Recipient consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
}
}()
}
func (s *Service) onRecipientNotification(
ctx context.Context,
objectType mservice.Type,
recipientRef, actorAccountRef bson.ObjectID,
action nm.NotificationAction,
) error {
if s.pmstore == nil {
return errStoreUnavailable
}
if objectType != mservice.Recipients || recipientRef == bson.NilObjectID {
return nil
}
switch action {
case nm.NAArchived:
updated, err := s.pmstore.SetArchivedByRecipient(ctx, recipientRef, true)
if err != nil {
s.logger.Warn("Failed to cascade archive payment methods by recipient",
zap.Error(err),
zap.String("recipient_ref", recipientRef.Hex()),
zap.String("actor_account_ref", actorAccountRef.Hex()))
return err
}
s.logger.Info("Recipient archive cascade applied to payment methods",
zap.String("recipient_ref", recipientRef.Hex()),
zap.String("actor_account_ref", actorAccountRef.Hex()),
zap.Int("updated_count", updated))
case nm.NADeleted:
if err := s.pmstore.DeleteByRecipient(ctx, recipientRef); err != nil {
s.logger.Warn("Failed to cascade delete payment methods by recipient",
zap.Error(err),
zap.String("recipient_ref", recipientRef.Hex()),
zap.String("actor_account_ref", actorAccountRef.Hex()))
return err
}
s.logger.Info("Recipient delete cascade applied to payment methods",
zap.String("recipient_ref", recipientRef.Hex()),
zap.String("actor_account_ref", actorAccountRef.Hex()))
}
return nil
}

View File

@@ -0,0 +1,90 @@
package methods
import (
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker"
"github.com/tech/sendico/pkg/mlogger"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
"google.golang.org/grpc"
)
var errStoreUnavailable = merrors.Internal("payment-methods: storage is not initialised")
// Option configures service dependencies.
type Option func(*Service)
// WithRecipientEventsBroker wires the broker used to consume recipient events.
func WithRecipientEventsBroker(broker mb.Broker) Option {
return func(s *Service) {
if broker != nil {
s.recipientBroker = broker
}
}
}
// Service implements payments.methods.v1.PaymentMethodsService.
type Service struct {
logger mlogger.Logger
storage storage.Repository
pmstore storage.PaymentMethodsStore
recipientBroker mb.Broker
recipientConsumers []msg.Consumer
methodsv1.UnimplementedPaymentMethodsServiceServer
}
// NewService creates a payment methods gRPC service.
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) (*Service, error) {
if logger == nil {
return nil, merrors.InvalidArgument("payment-methods: logger is required")
}
if repo == nil {
return nil, merrors.InvalidArgument("payment-methods: storage repository is required")
}
pmstore := repo.PaymentMethods()
if pmstore == nil {
return nil, errStoreUnavailable
}
svc := &Service{
logger: logger.Named("payment_methods"),
storage: repo,
pmstore: pmstore,
}
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
svc.startRecipientConsumers()
return svc, nil
}
// Register attaches the service to the supplied gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
methodsv1.RegisterPaymentMethodsServiceServer(reg, s)
})
}
// Shutdown releases underlying resources.
func (s *Service) Shutdown() {
if s == nil {
return
}
for _, consumer := range s.recipientConsumers {
if consumer != nil {
consumer.Close()
}
}
s.recipientConsumers = nil
s.pmstore = nil
s.storage = nil
}

View File

@@ -0,0 +1,37 @@
package methods
import (
"context"
"github.com/tech/sendico/pkg/merrors"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
)
func (s *Service) UpdatePaymentMethod(ctx context.Context, req *methodsv1.UpdatePaymentMethodRequest) (*methodsv1.UpdatePaymentMethodResponse, error) {
if req == nil {
return autoError[methodsv1.UpdatePaymentMethodResponse](ctx, s.logger, merrors.InvalidArgument("request is required"))
}
if s.pmstore == nil {
return autoError[methodsv1.UpdatePaymentMethodResponse](ctx, s.logger, errStoreUnavailable)
}
accountRef, err := parseObjectID(req.GetAccountRef(), "account_ref")
if err != nil {
return autoError[methodsv1.UpdatePaymentMethodResponse](ctx, s.logger, err)
}
pm, err := decodePaymentMethod(req.GetPaymentMethodJson())
if err != nil {
return autoError[methodsv1.UpdatePaymentMethodResponse](ctx, s.logger, err)
}
if err := s.pmstore.Update(ctx, accountRef, pm); err != nil {
return autoError[methodsv1.UpdatePaymentMethodResponse](ctx, s.logger, err)
}
payload, err := encodePaymentMethod(pm)
if err != nil {
return autoError[methodsv1.UpdatePaymentMethodResponse](ctx, s.logger, err)
}
return &methodsv1.UpdatePaymentMethodResponse{PaymentMethodJson: payload}, nil
}

View File

@@ -0,0 +1,83 @@
package methods
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
"go.mongodb.org/mongo-driver/v2/bson"
)
func autoError[T any](ctx context.Context, logger mlogger.Logger, err error) (*T, error) {
return gsresponse.Execute(ctx, gsresponse.Auto[T](logger, mservice.PaymentMethods, err))
}
func parseObjectID(value, field string) (bson.ObjectID, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return bson.NilObjectID, merrors.InvalidArgument(fmt.Sprintf("%s is required", field), field)
}
ref, err := bson.ObjectIDFromHex(trimmed)
if err != nil {
return bson.NilObjectID, merrors.InvalidArgument(fmt.Sprintf("%s must be a valid object id", field), field)
}
return ref, nil
}
func decodePaymentMethod(data []byte) (*model.PaymentMethod, error) {
if len(data) == 0 {
return nil, merrors.InvalidArgument("payment_method_json is required", "payment_method_json")
}
res := &model.PaymentMethod{}
if err := json.Unmarshal(data, res); err != nil {
return nil, merrors.InvalidArgumentWrap(err, "failed to decode payment method", "payment_method_json")
}
return res, nil
}
func encodePaymentMethod(pm *model.PaymentMethod) ([]byte, error) {
if pm == nil {
return nil, merrors.InvalidArgument("payment method is required")
}
payload, err := json.Marshal(pm)
if err != nil {
return nil, merrors.InternalWrap(err, "failed to encode payment method")
}
return payload, nil
}
func toModelCursor(cursor *methodsv1.ViewCursor) *model.ViewCursor {
if cursor == nil {
return nil
}
res := &model.ViewCursor{}
hasAny := false
if limit := cursor.GetLimit(); limit != nil {
v := limit.GetValue()
res.Limit = &v
hasAny = true
}
if offset := cursor.GetOffset(); offset != nil {
v := offset.GetValue()
res.Offset = &v
hasAny = true
}
if archived := cursor.GetIsArchived(); archived != nil {
v := archived.GetValue()
res.IsArchived = &v
hasAny = true
}
if !hasAny {
return nil
}
return res
}