package paymethodsimp import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" methodsclient "github.com/tech/sendico/payments/methods/client" api "github.com/tech/sendico/pkg/api/http" "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" archivablev1 "github.com/tech/sendico/pkg/proto/common/archivable/v1" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" oboundv1 "github.com/tech/sendico/pkg/proto/common/organization_bound/v1" paginationv2 "github.com/tech/sendico/pkg/proto/common/pagination/v2" pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1" storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" eapi "github.com/tech/sendico/server/interface/api" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/wrapperspb" ) type PaymentMethodsAPI struct { logger mlogger.Logger client methodsclient.Client oph mutil.ParamHelper rph mutil.ParamHelper mph mutil.ParamHelper } func (a *PaymentMethodsAPI) Name() mservice.Type { return mservice.PaymentMethods } func (a *PaymentMethodsAPI) Finish(_ context.Context) error { if a.client != nil { return a.client.Close() } return nil } func CreateAPI(apiCtx eapi.API) (*PaymentMethodsAPI, error) { logger := apiCtx.Logger().Named(mservice.PaymentMethods) cfg := apiCtx.Config().PaymentMethods if cfg == nil { return nil, merrors.InvalidArgument("payment methods configuration is not provided") } address, err := resolveClientAddress("payment methods", cfg) if err != nil { return nil, err } clientCfg := methodsclient.Config{ Address: address, DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second, CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second, Insecure: cfg.Insecure, } client, err := methodsclient.New(context.Background(), clientCfg) if err != nil { return nil, err } res := &PaymentMethodsAPI{ logger: logger, client: client, oph: mutil.CreatePH(mservice.Organizations), rph: mutil.CreatePH(mservice.Recipients), mph: mutil.CreatePH(mservice.PaymentMethods), } apiCtx.Register().AccountHandler(res.Name(), res.oph.AddRef("/"), api.Post, res.create) apiCtx.Register().AccountHandler(res.Name(), res.rph.AddRef(res.oph.AddRef("/list")), api.Get, res.list) apiCtx.Register().AccountHandler(res.Name(), res.mph.AddRef("/"), api.Get, res.get) apiCtx.Register().AccountHandler(res.Name(), "/", api.Put, res.update) apiCtx.Register().AccountHandler(res.Name(), res.mph.AddRef("/"), api.Delete, res.delete) apiCtx.Register().AccountHandler(res.Name(), res.mph.AddRef(res.oph.AddRef("/archive")), api.Get, res.archive) return res, nil } func (a *PaymentMethodsAPI) create(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { orgRef, err := a.oph.GetRef(r) if err != nil { return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) } payload, err := io.ReadAll(r.Body) if err != nil { return response.BadPayload(a.logger, a.Name(), err) } pm, err := decodePaymentMethodJSON(payload) if err != nil { return response.BadPayload(a.logger, a.Name(), err) } method, err := encodePaymentMethodProto(pm) if err != nil { return response.Internal(a.logger, a.Name(), err) } resp, err := a.client.CreatePaymentMethod(r.Context(), &methodsv1.CreatePaymentMethodRequest{ AccountRef: account.ID.Hex(), OrganizationRef: orgRef.Hex(), PaymentMethod: method, }) if err != nil { return grpcErrorResponse(a.logger, a.Name(), err) } pm, err = decodePaymentMethodRecord(resp.GetPaymentMethodRecord()) if err != nil { return response.Internal(a.logger, a.Name(), err) } return sresponse.ObjectAuthCreated(a.logger, pm, token, a.Name()) } func (a *PaymentMethodsAPI) list(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { orgRef, err := a.oph.GetRef(r) if err != nil { return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) } recipientRef, err := a.rph.GetRef(r) if err != nil { return response.BadReference(a.logger, a.Name(), a.rph.Name(), a.rph.GetID(r), err) } cursor, err := mutil.GetViewCursor(a.logger, r) if err != nil { return response.Auto(a.logger, a.Name(), err) } resp, err := a.client.ListPaymentMethods(r.Context(), &methodsv1.ListPaymentMethodsRequest{ AccountRef: account.ID.Hex(), OrganizationRef: orgRef.Hex(), RecipientRef: recipientRef.Hex(), Cursor: toProtoCursor(cursor), }) if err != nil { return grpcErrorResponse(a.logger, a.Name(), err) } items, err := decodePaymentMethods(resp.GetPaymentMethods()) if err != nil { return response.Internal(a.logger, a.Name(), err) } return sresponse.ObjectsAuth(a.logger, items, token, a.Name()) } func (a *PaymentMethodsAPI) get(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { methodRef, err := a.mph.GetRef(r) if err != nil { return response.BadReference(a.logger, a.Name(), a.mph.Name(), a.mph.GetID(r), err) } resp, err := a.client.GetPaymentMethod(r.Context(), &methodsv1.GetPaymentMethodRequest{ AccountRef: account.ID.Hex(), PaymentMethodRef: methodRef.Hex(), }) if err != nil { return grpcErrorResponse(a.logger, a.Name(), err) } pm, err := decodePaymentMethodRecord(resp.GetPaymentMethodRecord()) if err != nil { return response.Internal(a.logger, a.Name(), err) } return sresponse.ObjectAuth(a.logger, pm, token, a.Name()) } func (a *PaymentMethodsAPI) update(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { payload, err := io.ReadAll(r.Body) if err != nil { return response.BadPayload(a.logger, a.Name(), err) } pm, err := decodePaymentMethodJSON(payload) if err != nil { return response.BadPayload(a.logger, a.Name(), err) } record, err := encodePaymentMethodRecord(pm) if err != nil { return response.Internal(a.logger, a.Name(), err) } resp, err := a.client.UpdatePaymentMethod(r.Context(), &methodsv1.UpdatePaymentMethodRequest{ AccountRef: account.ID.Hex(), PaymentMethodRecord: record, }) if err != nil { return grpcErrorResponse(a.logger, a.Name(), err) } pm, err = decodePaymentMethodRecord(resp.GetPaymentMethodRecord()) if err != nil { return response.Internal(a.logger, a.Name(), err) } return sresponse.ObjectAuth(a.logger, pm, token, a.Name()) } func (a *PaymentMethodsAPI) delete(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { methodRef, err := a.mph.GetRef(r) if err != nil { return response.BadReference(a.logger, a.Name(), a.mph.Name(), a.mph.GetID(r), err) } cascade, err := mutil.GetCascadeParam(a.logger, r) if err != nil { return response.Auto(a.logger, a.Name(), err) } cascadeValue := false if cascade != nil { cascadeValue = *cascade } _, err = a.client.DeletePaymentMethod(r.Context(), &methodsv1.DeletePaymentMethodRequest{ AccountRef: account.ID.Hex(), PaymentMethodRef: methodRef.Hex(), Cascade: cascadeValue, }) if err != nil { return grpcErrorResponse(a.logger, a.Name(), err) } return sresponse.ObjectsAuth(a.logger, []model.PaymentMethod{}, token, a.Name()) } func (a *PaymentMethodsAPI) archive(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { methodRef, err := a.mph.GetRef(r) if err != nil { return response.BadReference(a.logger, a.Name(), a.mph.Name(), a.mph.GetID(r), err) } orgRef, err := a.oph.GetRef(r) if err != nil { return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) } archived, err := mutil.GetArchiveParam(a.logger, r) if err != nil { return response.Auto(a.logger, a.Name(), err) } if archived == nil { return response.BadRequest(a.logger, a.Name(), "invalid_query_parameter", "'archived' param must be present") } cascade, err := mutil.GetCascadeParam(a.logger, r) if err != nil { return response.Auto(a.logger, a.Name(), err) } cascadeValue := false if cascade != nil { cascadeValue = *cascade } _, err = a.client.SetPaymentMethodArchived(r.Context(), &methodsv1.SetPaymentMethodArchivedRequest{ AccountRef: account.ID.Hex(), OrganizationRef: orgRef.Hex(), PaymentMethodRef: methodRef.Hex(), Archived: *archived, Cascade: cascadeValue, }) if err != nil { return grpcErrorResponse(a.logger, a.Name(), err) } return sresponse.ObjectsAuth(a.logger, []model.PaymentMethod{}, token, a.Name()) } func resolveClientAddress(service string, cfg *eapi.PaymentOrchestratorConfig) (string, error) { if cfg == nil { return "", merrors.InvalidArgument(strings.TrimSpace(service) + " configuration is not provided") } address := strings.TrimSpace(cfg.Address) if address != "" { return address, nil } if env := strings.TrimSpace(cfg.AddressEnv); env != "" { if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" { return resolved, nil } return "", merrors.InvalidArgument(service + " address is not specified and address env " + env + " is empty") } return "", merrors.InvalidArgument(strings.TrimSpace(service) + " address is not specified") } func toProtoCursor(cursor *model.ViewCursor) *paginationv2.ViewCursor { if cursor == nil { return nil } res := &paginationv2.ViewCursor{} hasAny := false if cursor.Limit != nil { res.Limit = wrapperspb.Int64(*cursor.Limit) hasAny = true } if cursor.Offset != nil { res.Offset = wrapperspb.Int64(*cursor.Offset) hasAny = true } if cursor.IsArchived != nil { res.IsArchived = wrapperspb.Bool(*cursor.IsArchived) hasAny = true } if !hasAny { return nil } return res } func decodePaymentMethodJSON(payload []byte) (*model.PaymentMethod, error) { var pm model.PaymentMethod if err := json.Unmarshal(payload, &pm); err != nil { return nil, err } return &pm, nil } func decodePaymentMethodRecord(record *endpointv1.PaymentMethodRecord) (*model.PaymentMethod, error) { if record == nil { return nil, merrors.InvalidArgument("payment_method_record is required") } pm, err := decodePaymentMethodProto(record.GetPaymentMethod()) if err != nil { return nil, err } if err := applyPermissionBound(pm, record.GetPermissionBound()); err != nil { return nil, err } return pm, nil } func decodePaymentMethodProto(method *endpointv1.PaymentMethod) (*model.PaymentMethod, error) { if method == nil { return nil, merrors.InvalidArgument("payment_method is required") } recipientRef, err := parseRequiredObjectID(method.GetRecipientRef(), "payment_method.recipient_ref") if err != nil { return nil, err } pt, err := paymentTypeFromProto(method.GetType(), "payment_method.type") if err != nil { return nil, err } return &model.PaymentMethod{ Describable: describableFromProto(method.GetDescribable()), RecipientRef: recipientRef, Type: pt, Data: cloneBytes(method.GetData()), IsMain: method.GetIsMain(), }, nil } func encodePaymentMethodProto(pm *model.PaymentMethod) (*endpointv1.PaymentMethod, error) { if pm == nil { return nil, merrors.InvalidArgument("payment method is required") } pt, err := paymentTypeToProto(pm.Type) if err != nil { return nil, err } return &endpointv1.PaymentMethod{ Describable: describableToProto(pm.Describable), RecipientRef: toObjectHex(pm.RecipientRef), Type: pt, Data: cloneBytes(pm.Data), IsMain: pm.IsMain, }, nil } func encodePaymentMethodRecord(pm *model.PaymentMethod) (*endpointv1.PaymentMethodRecord, error) { method, err := encodePaymentMethodProto(pm) if err != nil { return nil, err } return &endpointv1.PaymentMethodRecord{ PermissionBound: permissionBoundFromModel(pm), PaymentMethod: method, }, nil } func decodePaymentMethods(items []*endpointv1.PaymentMethodRecord) ([]model.PaymentMethod, error) { if len(items) == 0 { return nil, nil } res := make([]model.PaymentMethod, 0, len(items)) for i := range items { pm, err := decodePaymentMethodRecord(items[i]) if err != nil { return nil, err } res = append(res, *pm) } return res, nil } func paymentTypeFromProto(value endpointv1.PaymentMethodType, field string) (model.PaymentType, error) { switch value { case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN: return model.PaymentTypeIban, nil case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD: return model.PaymentTypeCard, nil case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN: return model.PaymentTypeCardToken, nil case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT: return model.PaymentTypeBankAccount, nil case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET: return model.PaymentTypeWallet, nil case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS: return model.PaymentTypeCryptoAddress, nil case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER: return model.PaymentTypeLedger, nil default: return model.PaymentTypeIban, merrors.InvalidArgument(fmt.Sprintf("%s has unsupported value: %s", field, value.String()), field) } } func paymentTypeToProto(value model.PaymentType) (endpointv1.PaymentMethodType, error) { switch value { case model.PaymentTypeIban: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN, nil case model.PaymentTypeCard: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, nil case model.PaymentTypeCardToken: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, nil case model.PaymentTypeBankAccount: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT, nil case model.PaymentTypeWallet: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, nil case model.PaymentTypeCryptoAddress: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, nil case model.PaymentTypeLedger: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, nil default: return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("unsupported payment method type: %s", value.String()), "type") } } func describableFromProto(src *describablev1.Describable) model.Describable { if src == nil { return model.Describable{} } res := model.Describable{Name: src.GetName()} if src.Description != nil { v := src.GetDescription() res.Description = &v } return res } func describableToProto(src model.Describable) *describablev1.Describable { if strings.TrimSpace(src.Name) == "" && src.Description == nil { return nil } res := &describablev1.Describable{ Name: src.Name, } if src.Description != nil { v := *src.Description res.Description = &v } return res } func cloneBytes(src []byte) []byte { if len(src) == 0 { return nil } dst := make([]byte, len(src)) copy(dst, src) return dst } func permissionBoundFromModel(pm *model.PaymentMethod) *pboundv1.PermissionBound { if pm == nil { return nil } return &pboundv1.PermissionBound{ Storable: &storablev1.Storable{ Id: toObjectHex(pm.ID), CreatedAt: toProtoTime(pm.CreatedAt), UpdatedAt: toProtoTime(pm.UpdatedAt), }, Archivable: &archivablev1.Archivable{ IsArchived: pm.Archived, }, OrganizationBound: &oboundv1.OrganizationBound{ OrganizationRef: toObjectHex(pm.GetOrganizationRef()), }, PermissionRef: toObjectHex(pm.GetPermissionRef()), } } func applyPermissionBound(pm *model.PaymentMethod, src *pboundv1.PermissionBound) error { if pm == nil || src == nil { return nil } if storable := src.GetStorable(); storable != nil { if methodRef, err := parseOptionalObjectID(storable.GetId(), "payment_method_record.permission_bound.storable.id"); err != nil { return err } else if methodRef != bson.NilObjectID { pm.ID = methodRef } pm.CreatedAt = fromProtoTime(storable.GetCreatedAt()) pm.UpdatedAt = fromProtoTime(storable.GetUpdatedAt()) } if archivable := src.GetArchivable(); archivable != nil { pm.Archived = archivable.GetIsArchived() } if orgBound := src.GetOrganizationBound(); orgBound != nil { if orgRef, err := parseOptionalObjectID(orgBound.GetOrganizationRef(), "payment_method_record.permission_bound.organization_bound.organization_ref"); err != nil { return err } else if orgRef != bson.NilObjectID { pm.SetOrganizationRef(orgRef) } } if permissionRef, err := parseOptionalObjectID(src.GetPermissionRef(), "payment_method_record.permission_bound.permission_ref"); err != nil { return err } else if permissionRef != bson.NilObjectID { pm.SetPermissionRef(permissionRef) } return nil } func parseOptionalObjectID(value, field string) (bson.ObjectID, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return bson.NilObjectID, nil } 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 parseRequiredObjectID(value, field string) (bson.ObjectID, error) { ref, err := parseOptionalObjectID(value, field) if err != nil { return bson.NilObjectID, err } if ref == bson.NilObjectID { return bson.NilObjectID, merrors.InvalidArgument(field+" is required", field) } return ref, nil } func toObjectHex(value bson.ObjectID) string { if value == bson.NilObjectID { return "" } return value.Hex() } func toProtoTime(value time.Time) *timestamppb.Timestamp { if value.IsZero() { return nil } return timestamppb.New(value) } func fromProtoTime(value *timestamppb.Timestamp) time.Time { if value == nil { return time.Time{} } return value.AsTime() } func grpcErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc { statusErr, ok := status.FromError(err) if !ok { return response.Internal(logger, source, err) } switch statusErr.Code() { case codes.InvalidArgument: return response.BadRequest(logger, source, "invalid_argument", statusErr.Message()) case codes.NotFound: return response.NotFound(logger, source, statusErr.Message()) case codes.PermissionDenied: return response.AccessDenied(logger, source, statusErr.Message()) case codes.Unauthenticated: return response.Unauthorized(logger, source, statusErr.Message()) case codes.AlreadyExists, codes.Aborted: return response.DataConflict(logger, source, statusErr.Message()) case codes.Unimplemented: return response.NotImplemented(logger, source, statusErr.Message()) case codes.FailedPrecondition: return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message()) case codes.DeadlineExceeded: return response.Error(logger, source, http.StatusGatewayTimeout, "deadline_exceeded", statusErr.Message()) case codes.Unavailable: return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message()) default: return response.Internal(logger, source, err) } }