move api/server to api/edge/bff
This commit is contained in:
422
api/edge/bff/interface/accountservice/internal/service.go
Normal file
422
api/edge/bff/interface/accountservice/internal/service.go
Normal file
@@ -0,0 +1,422 @@
|
||||
package accountserviceimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/auth/management"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
"github.com/tech/sendico/pkg/db/organization"
|
||||
"github.com/tech/sendico/pkg/db/policy"
|
||||
"github.com/tech/sendico/pkg/db/verification"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
logger mlogger.Logger
|
||||
accountDB account.DB
|
||||
orgDB organization.DB
|
||||
enforcer auth.Enforcer
|
||||
roleManager management.Role
|
||||
config *middleware.PasswordConfig
|
||||
|
||||
policyDB policy.DB
|
||||
vdb verification.DB
|
||||
}
|
||||
|
||||
func validateUserRequest(u *model.Account) error {
|
||||
if u.Name == "" {
|
||||
return merrors.InvalidArgument("Name must not be empty")
|
||||
}
|
||||
if u.Login == "" {
|
||||
return merrors.InvalidArgument("Login must not be empty")
|
||||
}
|
||||
if u.Password == "" {
|
||||
return merrors.InvalidArgument("Password must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ValidatePassword(
|
||||
password string,
|
||||
oldPassword *string,
|
||||
) error {
|
||||
var hasDigit, hasUpper, hasLower, hasSpecial bool
|
||||
|
||||
if oldPassword != nil {
|
||||
if *oldPassword == password {
|
||||
return merrors.InvalidArgument("New password cannot be the same as the old password")
|
||||
}
|
||||
}
|
||||
|
||||
if len(password) < s.config.Check.MinLength {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("Password must be at least %d characters long", s.config.Check.MinLength))
|
||||
}
|
||||
|
||||
// Check for digit, uppercase, lowercase, and special character
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case unicode.IsDigit(char):
|
||||
hasDigit = true
|
||||
case unicode.IsUpper(char):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(char):
|
||||
hasLower = true
|
||||
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if s.config.Check.Digit && !hasDigit {
|
||||
return merrors.InvalidArgument("Password must contain at least one digit")
|
||||
}
|
||||
if s.config.Check.Upper && !hasUpper {
|
||||
return merrors.InvalidArgument("Password must contain at least one uppercase letter")
|
||||
}
|
||||
if s.config.Check.Lower && !hasLower {
|
||||
return merrors.InvalidArgument("Password must contain at least one lowercase letter")
|
||||
}
|
||||
if s.config.Check.Special && !hasSpecial {
|
||||
return merrors.InvalidArgument("Password must contain at least one special character")
|
||||
}
|
||||
|
||||
// If all checks pass, return nil (no error)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ValidateAccount(acct *model.Account) error {
|
||||
if err := validateUserRequest(acct); err != nil {
|
||||
s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.ValidatePassword(acct.Password, nil); err != nil {
|
||||
s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := acct.HashPassword(); err != nil {
|
||||
s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) CreateAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
acct *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) (string, error) {
|
||||
if org == nil {
|
||||
return "", merrors.InvalidArgument("Organization must not be nil")
|
||||
}
|
||||
if acct == nil || len(acct.Login) == 0 {
|
||||
return "", merrors.InvalidArgument("Account must have a non-empty login")
|
||||
}
|
||||
if roleDescID == bson.NilObjectID {
|
||||
return "", merrors.InvalidArgument("Role description must be provided")
|
||||
}
|
||||
|
||||
// 1) Create the account
|
||||
acct.Status = model.AccountPendingVerification
|
||||
if err := s.accountDB.Create(ctx, acct); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
s.logger.Info("Username is already taken", zap.String("login", acct.Login))
|
||||
} else {
|
||||
s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2) Add to organization
|
||||
if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil {
|
||||
s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 3) Issue verification token
|
||||
return s.VerifyAccount(ctx, acct)
|
||||
}
|
||||
|
||||
func (s *service) VerifyAccount(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (string, error) {
|
||||
token, err := s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, "").
|
||||
WithTTL(time.Duration(time.Hour*24)),
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to create verification token for new account", zap.Error(err), mzap.StorableRef(acct))
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
||||
}
|
||||
|
||||
func (s *service) DeleteAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error {
|
||||
// Check if this is the only member in the organization
|
||||
if len(org.Members) <= 1 {
|
||||
s.logger.Warn("Cannot delete account - it's the only member in the organization",
|
||||
mzap.AccRef(accountRef), mzap.StorableRef(org))
|
||||
return merrors.InvalidArgument("Cannot delete the only member of an organization")
|
||||
}
|
||||
|
||||
// 1) Remove from organization
|
||||
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// 2) Delete the account document
|
||||
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) RemoveAccountFromOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error {
|
||||
if org == nil {
|
||||
return merrors.InvalidArgument("Organization must not be nil")
|
||||
}
|
||||
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
|
||||
mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
|
||||
s.logger.Warn("Failed to revoke account role", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, member := range org.Members {
|
||||
if member == accountRef {
|
||||
// Remove the member by slicing it out
|
||||
org.Members = append(org.Members[:i], org.Members[i+1:]...)
|
||||
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
|
||||
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ResetPassword(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (string, error) {
|
||||
return s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, "").
|
||||
WithTTL(time.Duration(time.Hour*1)),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *service) UpdateLogin(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
newLogin string,
|
||||
) (string, error) {
|
||||
return s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin).
|
||||
WithTTL(time.Duration(time.Hour*1)),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *service) JoinOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
account *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) error {
|
||||
if slices.Contains(org.Members, account.ID) {
|
||||
s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account))
|
||||
return nil
|
||||
}
|
||||
|
||||
role := &model.Role{
|
||||
DescriptionRef: roleDescID,
|
||||
OrganizationRef: org.ID,
|
||||
AccountRef: account.ID,
|
||||
}
|
||||
if err := s.roleManager.Assign(ctx, role); err != nil {
|
||||
s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account),
|
||||
mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID))
|
||||
return err
|
||||
}
|
||||
|
||||
org.Members = append(org.Members, account.ID)
|
||||
if err := s.orgDB.Update(ctx, *account.GetID(), org); err != nil {
|
||||
s.logger.Warn("Failed to update organization members list",
|
||||
zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error {
|
||||
s.logger.Info("Deleting roles for organization", mzap.ObjRef("organization_ref", orgRef))
|
||||
|
||||
// Get all roles for the organization
|
||||
roles, err := s.roleManager.List(ctx, orgRef)
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
s.logger.Warn("Failed to fetch roles for deletion", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete each role
|
||||
for _, role := range roles {
|
||||
if err := s.roleManager.Delete(ctx, role.ID); err != nil {
|
||||
s.logger.Warn("Failed to delete role", zap.Error(err), mzap.ObjRef("role_ref", role.ID))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("Successfully deleted roles", zap.Int("count", len(roles)), mzap.ObjRef("organization_ref", orgRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error {
|
||||
s.logger.Info("Deleting policies for organization", mzap.ObjRef("organization_ref", orgRef))
|
||||
|
||||
// Get all policies for the organization
|
||||
policies, err := s.policyDB.All(ctx, orgRef)
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
s.logger.Warn("Failed to fetch policies for deletion", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete each policy
|
||||
for _, policy := range policies {
|
||||
if err := s.policyDB.Delete(ctx, policy.ID); err != nil {
|
||||
s.logger.Warn("Failed to delete policy", zap.Error(err), mzap.ObjRef("policy_ref", policy.ID))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("Successfully deleted policies", zap.Int("count", len(policies)), mzap.ObjRef("organization_ref", orgRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) DeleteOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
) error {
|
||||
s.logger.Info("Starting organization deletion", mzap.StorableRef(org))
|
||||
|
||||
// Use transaction to ensure atomicity
|
||||
// 8. Delete all roles and role descriptions in the organization
|
||||
if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 9. Delete all policies in the organization
|
||||
if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 10. Finally, delete the organization itself
|
||||
if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil {
|
||||
s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Organization deleted successfully", mzap.StorableRef(org))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) DeleteAll(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error {
|
||||
s.logger.Info("Starting complete deletion (organization + account)",
|
||||
mzap.StorableRef(org), mzap.AccRef(accountRef))
|
||||
|
||||
// 1. First delete the organization and all its data
|
||||
if err := s.DeleteOrganization(ctx, org); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Then delete the account
|
||||
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAccountService wires in your logger plus the three dependencies.
|
||||
func NewAccountService(
|
||||
l mlogger.Logger,
|
||||
dbf db.Factory,
|
||||
enforcer auth.Enforcer,
|
||||
ra management.Role,
|
||||
config *middleware.PasswordConfig,
|
||||
) (*service, error) {
|
||||
logger := l.Named("account_service")
|
||||
|
||||
if config == nil {
|
||||
return nil, merrors.Internal("Invalid account service configuration provides")
|
||||
}
|
||||
|
||||
res := &service{
|
||||
logger: logger,
|
||||
enforcer: enforcer,
|
||||
roleManager: ra,
|
||||
config: config,
|
||||
}
|
||||
var err error
|
||||
if res.accountDB, err = dbf.NewAccountDB(); err != nil {
|
||||
logger.Warn("Failed to create accounts database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.orgDB, err = dbf.NewOrganizationDB(); err != nil {
|
||||
logger.Warn("Failed to create organizations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize database dependencies for cascade deletion
|
||||
if res.policyDB, err = dbf.NewPoliciesDB(); err != nil {
|
||||
logger.Warn("Failed to create policies database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.vdb, err = dbf.NewVerificationsDB(); err != nil {
|
||||
logger.Warn("Failed to create verification database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package accountserviceimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestDeleteAccount_Validation(t *testing.T) {
|
||||
t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
accountID := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Single Member Org"},
|
||||
},
|
||||
Members: []bson.ObjectID{accountID}, // Only one member
|
||||
}
|
||||
org.ID = orgID
|
||||
|
||||
// This should fail because it's the only member
|
||||
err := validateDeleteAccount(org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Cannot delete the only member")
|
||||
})
|
||||
|
||||
t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
accountID := bson.NewObjectID()
|
||||
otherAccountID := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Multi Member Org"},
|
||||
},
|
||||
Members: []bson.ObjectID{accountID, otherAccountID}, // Multiple members
|
||||
}
|
||||
org.ID = orgID
|
||||
|
||||
// This should succeed because there are multiple members
|
||||
err := validateDeleteAccount(org)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Empty Org"},
|
||||
},
|
||||
Members: []bson.ObjectID{}, // No members
|
||||
}
|
||||
org.ID = orgID
|
||||
|
||||
// This should fail because there are no members
|
||||
err := validateDeleteAccount(org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Cannot delete the only member")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteOrganization_Validation(t *testing.T) {
|
||||
t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) {
|
||||
err := validateDeleteOrganization(nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "organization cannot be nil")
|
||||
})
|
||||
|
||||
t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) {
|
||||
org := &model.Organization{}
|
||||
err := validateDeleteOrganization(org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "organization ID cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) {
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Valid Organization"},
|
||||
},
|
||||
}
|
||||
org.ID = bson.NewObjectID()
|
||||
|
||||
err := validateDeleteOrganization(org)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteAll_Validation(t *testing.T) {
|
||||
t.Run("DeleteAll_NilOrganization", func(t *testing.T) {
|
||||
accountID := bson.NewObjectID()
|
||||
err := validateDeleteAll(nil, accountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "organization cannot be nil")
|
||||
})
|
||||
|
||||
t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) {
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Valid Organization"},
|
||||
},
|
||||
}
|
||||
org.ID = bson.NewObjectID()
|
||||
|
||||
err := validateDeleteAll(org, bson.NilObjectID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "account ID cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("DeleteAll_ValidInput", func(t *testing.T) {
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Valid Organization"},
|
||||
},
|
||||
}
|
||||
org.ID = bson.NewObjectID()
|
||||
accountID := bson.NewObjectID()
|
||||
|
||||
err := validateDeleteAll(org, accountID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions that implement the validation logic from the service
|
||||
func validateDeleteAccount(org *model.Organization) error {
|
||||
if len(org.Members) <= 1 {
|
||||
return merrors.InvalidArgument("Cannot delete the only member of an organization")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeleteOrganization(org *model.Organization) error {
|
||||
if org == nil {
|
||||
return merrors.InvalidArgument("organization cannot be nil")
|
||||
}
|
||||
if org.ID == bson.NilObjectID {
|
||||
return merrors.InvalidArgument("organization ID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeleteAll(org *model.Organization, accountRef bson.ObjectID) error {
|
||||
if org == nil {
|
||||
return merrors.InvalidArgument("organization cannot be nil")
|
||||
}
|
||||
if accountRef == bson.NilObjectID {
|
||||
return merrors.InvalidArgument("account ID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
245
api/edge/bff/interface/accountservice/internal/service_test.go
Normal file
245
api/edge/bff/interface/accountservice/internal/service_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package accountserviceimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
apiconfig "github.com/tech/sendico/server/internal/api/config"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestValidatePassword tests the password validation logic directly
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 8,
|
||||
Digit: true,
|
||||
Upper: true,
|
||||
Lower: true,
|
||||
Special: true,
|
||||
},
|
||||
TokenLength: 32,
|
||||
}
|
||||
|
||||
// Create a minimal service for testing password validation
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
t.Run("ValidPassword", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TestPassword123!", nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("PasswordTooShort", func(t *testing.T) {
|
||||
err := service.ValidatePassword("Test1!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least 8 characters")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingDigit", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TestPassword!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one digit")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingUppercase", func(t *testing.T) {
|
||||
err := service.ValidatePassword("testpassword123!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one uppercase")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingLowercase", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TESTPASSWORD123!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one lowercase")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingSpecialCharacter", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TestPassword123", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one special character")
|
||||
})
|
||||
|
||||
t.Run("PasswordSameAsOld", func(t *testing.T) {
|
||||
oldPassword := "TestPassword123!"
|
||||
err := service.ValidatePassword("TestPassword123!", &oldPassword)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be the same as the old password")
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateAccount tests the account validation logic directly
|
||||
func TestValidateAccount(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 8,
|
||||
Digit: true,
|
||||
Upper: true,
|
||||
Lower: true,
|
||||
Special: true,
|
||||
},
|
||||
TokenLength: 32,
|
||||
}
|
||||
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
t.Run("ValidAccount", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
}
|
||||
|
||||
originalPassword := account.Password
|
||||
err := service.ValidateAccount(account)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Password should be hashed after validation
|
||||
assert.NotEqual(t, originalPassword, account.Password)
|
||||
})
|
||||
|
||||
t.Run("AccountMissingName", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Name must not be empty")
|
||||
})
|
||||
|
||||
t.Run("AccountMissingLogin", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Login must not be empty")
|
||||
})
|
||||
|
||||
t.Run("AccountMissingPassword", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "",
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Password must not be empty")
|
||||
})
|
||||
|
||||
t.Run("AccountInvalidPassword", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "weak", // Should fail validation
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
// Should fail on password validation
|
||||
assert.Contains(t, err.Error(), "at least 8 characters")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPasswordConfiguration verifies different password rule configurations
|
||||
func TestPasswordConfiguration(t *testing.T) {
|
||||
t.Run("MinimalRequirements", func(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 4,
|
||||
Digit: false,
|
||||
Upper: false,
|
||||
Lower: false,
|
||||
Special: false,
|
||||
},
|
||||
TokenLength: 16,
|
||||
}
|
||||
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Should pass with minimal requirements
|
||||
err := service.ValidatePassword("test", nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("StrictRequirements", func(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 12,
|
||||
Digit: true,
|
||||
Upper: true,
|
||||
Lower: true,
|
||||
Special: true,
|
||||
},
|
||||
TokenLength: 64,
|
||||
}
|
||||
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Should fail with shorter password
|
||||
err := service.ValidatePassword("Test123!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least 12 characters")
|
||||
|
||||
// Should pass with longer password
|
||||
err = service.ValidatePassword("TestPassword123!", nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
104
api/edge/bff/interface/accountservice/types.go
Normal file
104
api/edge/bff/interface/accountservice/types.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package accountservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/auth/management"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
accountserviceimp "github.com/tech/sendico/server/interface/accountservice/internal"
|
||||
"github.com/tech/sendico/server/interface/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// AccountService defines all account-related workflows.
|
||||
type AccountService interface {
|
||||
// ValidateAccount will:
|
||||
// 1) check it's completeness
|
||||
// 2) hash password
|
||||
// 3) prepare verification token
|
||||
ValidateAccount(
|
||||
acct *model.Account,
|
||||
) error
|
||||
|
||||
// ValidatePassword will:
|
||||
// 1) check passsword conformance
|
||||
ValidatePassword(
|
||||
password string,
|
||||
oldPassword *string,
|
||||
) error
|
||||
|
||||
// ResetPassword will:
|
||||
// 1) generate reset password token
|
||||
ResetPassword(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
// CreateAccount will:
|
||||
// 1) create the account
|
||||
// 2) add it to the org’s member list
|
||||
// 3) assign the given role description to it
|
||||
CreateAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
acct *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
VerifyAccount(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
JoinOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
acct *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) error
|
||||
|
||||
UpdateLogin(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
newLogin string,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
// DeleteAccount deletes the account and removes it from the org.
|
||||
DeleteAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
// RemoveAccountFromOrganization just drops it from the member slice.
|
||||
RemoveAccountFromOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
DeleteOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
) error
|
||||
|
||||
// DeleteAll deletes both the organization and the account.
|
||||
DeleteAll(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error
|
||||
}
|
||||
|
||||
func NewAccountService(
|
||||
logger mlogger.Logger,
|
||||
dbf db.Factory,
|
||||
enforcer auth.Enforcer,
|
||||
roleManeger management.Role,
|
||||
config *middleware.PasswordConfig,
|
||||
) (AccountService, error) {
|
||||
return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config)
|
||||
}
|
||||
20
api/edge/bff/interface/api/api.go
Normal file
20
api/edge/bff/interface/api/api.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/domainprovider"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
Logger() mlogger.Logger
|
||||
DomainProvider() domainprovider.DomainProvider
|
||||
Config() *Config
|
||||
DBFactory() db.Factory
|
||||
Permissions() auth.Provider
|
||||
Register() Register
|
||||
}
|
||||
|
||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||
47
api/edge/bff/interface/api/config.go
Normal file
47
api/edge/bff/interface/api/config.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
mwa "github.com/tech/sendico/server/interface/middleware"
|
||||
fsc "github.com/tech/sendico/server/interface/services/fileservice/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
Storage *fsc.Config `yaml:"storage"`
|
||||
ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"`
|
||||
Ledger *LedgerConfig `yaml:"ledger"`
|
||||
PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"`
|
||||
PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"`
|
||||
PaymentMethods *PaymentOrchestratorConfig `yaml:"payment_methods"`
|
||||
}
|
||||
|
||||
type ChainGatewayConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
AddressEnv string `yaml:"address_env"`
|
||||
DialTimeoutSeconds int `yaml:"dial_timeout_seconds"`
|
||||
CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
DefaultAsset ChainGatewayAssetConfig `yaml:"default_asset"`
|
||||
}
|
||||
|
||||
type ChainGatewayAssetConfig struct {
|
||||
Chain string `yaml:"chain"`
|
||||
TokenSymbol string `yaml:"token_symbol"`
|
||||
ContractAddress string `yaml:"contract_address"`
|
||||
}
|
||||
|
||||
type LedgerConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
AddressEnv string `yaml:"address_env"`
|
||||
DialTimeoutSeconds int `yaml:"dial_timeout_seconds"`
|
||||
CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
}
|
||||
|
||||
type PaymentOrchestratorConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
AddressEnv string `yaml:"address_env"`
|
||||
DialTimeoutSeconds int `yaml:"dial_timeout_seconds"`
|
||||
CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
}
|
||||
10
api/edge/bff/interface/api/permissions/deny.go
Normal file
10
api/edge/bff/interface/api/permissions/deny.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func Deny(_ *model.Account, _ *auth.Enforcer) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
10
api/edge/bff/interface/api/permissions/donotcheck.go
Normal file
10
api/edge/bff/interface/api/permissions/donotcheck.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func DoNotCheck(_ *model.Account, _ *auth.Enforcer) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
18
api/edge/bff/interface/api/register.go
Normal file
18
api/edge/bff/interface/api/register.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"github.com/tech/sendico/server/interface/api/ws"
|
||||
)
|
||||
|
||||
type Register interface {
|
||||
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||
WSHandler(messageType string, handler ws.HandlerFunc)
|
||||
|
||||
Messaging() messaging.Register
|
||||
}
|
||||
7
api/edge/bff/interface/api/srequest/acceptinvitation.go
Normal file
7
api/edge/bff/interface/api/srequest/acceptinvitation.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package srequest
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
type AcceptInvitation struct {
|
||||
Account *model.AccountData `json:"account,omitempty"`
|
||||
}
|
||||
12
api/edge/bff/interface/api/srequest/changepolicies.go
Normal file
12
api/edge/bff/interface/api/srequest/changepolicies.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type ChangePolicies struct {
|
||||
RoleRef bson.ObjectID `json:"roleRef"`
|
||||
Add *[]model.RolePolicy `json:"add,omitempty"`
|
||||
Remove *[]model.RolePolicy `json:"remove,omitempty"`
|
||||
}
|
||||
8
api/edge/bff/interface/api/srequest/changerole.go
Normal file
8
api/edge/bff/interface/api/srequest/changerole.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package srequest
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
type ChangeRole struct {
|
||||
AccountRef bson.ObjectID `json:"accountRef"`
|
||||
NewRoleDescriptionRef bson.ObjectID `json:"newRoleDescriptionRef"`
|
||||
}
|
||||
15
api/edge/bff/interface/api/srequest/customer.go
Normal file
15
api/edge/bff/interface/api/srequest/customer.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package srequest
|
||||
|
||||
// Customer captures payer/recipient identity details for downstream processing.
|
||||
type Customer struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Zip string `json:"zip,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
76
api/edge/bff/interface/api/srequest/endpoint_payloads.go
Normal file
76
api/edge/bff/interface/api/srequest/endpoint_payloads.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package srequest
|
||||
|
||||
// Asset represents a chain/token pair for blockchain endpoints.
|
||||
type Asset struct {
|
||||
Chain ChainNetwork `json:"chain"`
|
||||
TokenSymbol string `json:"token_symbol"`
|
||||
ContractAddress string `json:"contract_address,omitempty"`
|
||||
}
|
||||
|
||||
// LedgerEndpoint represents a ledger account payload.
|
||||
type LedgerEndpoint struct {
|
||||
LedgerAccountRef string `json:"ledger_account_ref"`
|
||||
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
|
||||
}
|
||||
|
||||
// ManagedWalletEndpoint represents a managed wallet payload.
|
||||
type ManagedWalletEndpoint struct {
|
||||
ManagedWalletRef string `json:"managed_wallet_ref"`
|
||||
Asset *Asset `json:"asset,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalChainEndpoint represents an external chain address payload.
|
||||
type ExternalChainEndpoint struct {
|
||||
Asset *Asset `json:"asset,omitempty"`
|
||||
Address string `json:"address"`
|
||||
Memo string `json:"memo,omitempty"`
|
||||
}
|
||||
|
||||
// CardEndpoint represents a card payout payload (PAN or network token).
|
||||
type CardEndpoint struct {
|
||||
Pan string `json:"pan"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
ExpMonth uint32 `json:"exp_month,omitempty"`
|
||||
ExpYear uint32 `json:"exp_year,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
// CardTokenEndpoint represents a vaulted card token payout payload.
|
||||
type CardTokenEndpoint struct {
|
||||
Token string `json:"token"`
|
||||
MaskedPan string `json:"masked_pan"`
|
||||
}
|
||||
|
||||
// WalletEndpoint represents a Sendico wallet payout payload.
|
||||
type WalletEndpoint struct {
|
||||
WalletID string `json:"walletId"`
|
||||
}
|
||||
|
||||
// BankAccountEndpoint represents a domestic bank account payout payload.
|
||||
type BankAccountEndpoint struct {
|
||||
RecipientName string `json:"recipientName"`
|
||||
Inn string `json:"inn"`
|
||||
Kpp string `json:"kpp"`
|
||||
BankName string `json:"bankName"`
|
||||
Bik string `json:"bik"`
|
||||
AccountNumber string `json:"accountNumber"`
|
||||
CorrespondentAccount string `json:"correspondentAccount"`
|
||||
}
|
||||
|
||||
// IBANEndpoint represents an international bank account payout payload.
|
||||
type IBANEndpoint struct {
|
||||
IBAN string `json:"iban"`
|
||||
AccountHolder string `json:"accountHolder"`
|
||||
BIC string `json:"bic,omitempty"`
|
||||
BankName string `json:"bankName,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyPaymentEndpoint mirrors the previous bag-of-pointers DTO for backward compatibility.
|
||||
type LegacyPaymentEndpoint struct {
|
||||
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
|
||||
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
|
||||
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
|
||||
Card *CardEndpoint `json:"card,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
266
api/edge/bff/interface/api/srequest/endpoint_union.go
Normal file
266
api/edge/bff/interface/api/srequest/endpoint_union.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
type EndpointType string
|
||||
|
||||
const (
|
||||
EndpointTypeLedger EndpointType = "ledger"
|
||||
EndpointTypeManagedWallet EndpointType = "managedWallet"
|
||||
EndpointTypeExternalChain EndpointType = "cryptoAddress"
|
||||
EndpointTypeCard EndpointType = "card"
|
||||
EndpointTypeCardToken EndpointType = "cardToken"
|
||||
EndpointTypeWallet EndpointType = "wallet"
|
||||
EndpointTypeBankAccount EndpointType = "bankAccount"
|
||||
EndpointTypeIBAN EndpointType = "iban"
|
||||
)
|
||||
|
||||
// Endpoint is a discriminated union for payment endpoints.
|
||||
type Endpoint struct {
|
||||
Type EndpointType `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func newEndpoint(kind EndpointType, payload interface{}, metadata map[string]string) (Endpoint, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Endpoint{}, merrors.Internal("marshal endpoint payload failed")
|
||||
}
|
||||
return Endpoint{
|
||||
Type: kind,
|
||||
Data: data,
|
||||
Metadata: cloneStringMap(metadata),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e Endpoint) decodePayload(expected EndpointType, dst interface{}) error {
|
||||
actual := normalizeEndpointType(e.Type)
|
||||
if actual == "" {
|
||||
return merrors.InvalidArgument("endpoint type is required")
|
||||
}
|
||||
if actual != expected {
|
||||
return merrors.InvalidArgument("expected endpoint type " + string(expected) + ", got " + string(e.Type))
|
||||
}
|
||||
if len(e.Data) == 0 {
|
||||
return merrors.InvalidArgument("endpoint data is required for type " + string(expected))
|
||||
}
|
||||
if err := json.Unmarshal(e.Data, dst); err != nil {
|
||||
return merrors.InvalidArgument("decode " + string(expected) + " endpoint: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) UnmarshalJSON(data []byte) error {
|
||||
var envelope struct {
|
||||
Type EndpointType `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &envelope); err == nil {
|
||||
if envelope.Type != "" || len(envelope.Data) > 0 {
|
||||
if envelope.Type == "" {
|
||||
return merrors.InvalidArgument("endpoint type is required")
|
||||
}
|
||||
*e = Endpoint{
|
||||
Type: normalizeEndpointType(envelope.Type),
|
||||
Data: envelope.Data,
|
||||
Metadata: cloneStringMap(envelope.Metadata),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var legacy LegacyPaymentEndpoint
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint, err := LegacyPaymentEndpointToEndpointDTO(&legacy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if endpoint == nil {
|
||||
return merrors.InvalidArgument("endpoint payload is empty")
|
||||
}
|
||||
*e = *endpoint
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewLedgerEndpointDTO(payload LedgerEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeLedger, payload, metadata)
|
||||
}
|
||||
|
||||
func NewManagedWalletEndpointDTO(payload ManagedWalletEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeManagedWallet, payload, metadata)
|
||||
}
|
||||
|
||||
func NewExternalChainEndpointDTO(payload ExternalChainEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeExternalChain, payload, metadata)
|
||||
}
|
||||
|
||||
func NewCardEndpointDTO(payload CardEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeCard, payload, metadata)
|
||||
}
|
||||
|
||||
func NewCardTokenEndpointDTO(payload CardTokenEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeCardToken, payload, metadata)
|
||||
}
|
||||
|
||||
func NewWalletEndpointDTO(payload WalletEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeWallet, payload, metadata)
|
||||
}
|
||||
|
||||
func NewBankAccountEndpointDTO(payload BankAccountEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeBankAccount, payload, metadata)
|
||||
}
|
||||
|
||||
func NewIBANEndpointDTO(payload IBANEndpoint, metadata map[string]string) (Endpoint, error) {
|
||||
return newEndpoint(EndpointTypeIBAN, payload, metadata)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeLedger() (LedgerEndpoint, error) {
|
||||
var payload LedgerEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeLedger, &payload)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeManagedWallet() (ManagedWalletEndpoint, error) {
|
||||
var payload ManagedWalletEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeManagedWallet, &payload)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeExternalChain() (ExternalChainEndpoint, error) {
|
||||
var payload ExternalChainEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeExternalChain, &payload)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeCard() (CardEndpoint, error) {
|
||||
var payload CardEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeCard, &payload)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeCardToken() (CardTokenEndpoint, error) {
|
||||
var payload CardTokenEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeCardToken, &payload)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeWallet() (WalletEndpoint, error) {
|
||||
var payload WalletEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeWallet, &payload)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeBankAccount() (BankAccountEndpoint, error) {
|
||||
var payload BankAccountEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeBankAccount, &payload)
|
||||
}
|
||||
|
||||
func (e Endpoint) DecodeIBAN() (IBANEndpoint, error) {
|
||||
var payload IBANEndpoint
|
||||
return payload, e.decodePayload(EndpointTypeIBAN, &payload)
|
||||
}
|
||||
|
||||
func LegacyPaymentEndpointToEndpointDTO(old *LegacyPaymentEndpoint) (*Endpoint, error) {
|
||||
if old == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
count := 0
|
||||
var endpoint Endpoint
|
||||
var err error
|
||||
|
||||
if old.Ledger != nil {
|
||||
count++
|
||||
endpoint, err = NewLedgerEndpointDTO(*old.Ledger, old.Metadata)
|
||||
}
|
||||
if old.ManagedWallet != nil {
|
||||
count++
|
||||
endpoint, err = NewManagedWalletEndpointDTO(*old.ManagedWallet, old.Metadata)
|
||||
}
|
||||
if old.ExternalChain != nil {
|
||||
count++
|
||||
endpoint, err = NewExternalChainEndpointDTO(*old.ExternalChain, old.Metadata)
|
||||
}
|
||||
if old.Card != nil {
|
||||
count++
|
||||
endpoint, err = NewCardEndpointDTO(*old.Card, old.Metadata)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, merrors.InvalidArgument("exactly one endpoint must be set")
|
||||
}
|
||||
if count > 1 {
|
||||
return nil, merrors.InvalidArgument("only one endpoint can be set")
|
||||
}
|
||||
return &endpoint, nil
|
||||
}
|
||||
|
||||
func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint, error) {
|
||||
if new == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
legacy := &LegacyPaymentEndpoint{
|
||||
Metadata: cloneStringMap(new.Metadata),
|
||||
}
|
||||
|
||||
switch normalizeEndpointType(new.Type) {
|
||||
case EndpointTypeLedger:
|
||||
payload, err := new.DecodeLedger()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacy.Ledger = &payload
|
||||
case EndpointTypeManagedWallet:
|
||||
payload, err := new.DecodeManagedWallet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacy.ManagedWallet = &payload
|
||||
case EndpointTypeExternalChain:
|
||||
payload, err := new.DecodeExternalChain()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacy.ExternalChain = &payload
|
||||
case EndpointTypeCard:
|
||||
payload, err := new.DecodeCard()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacy.Card = &payload
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("unsupported endpoint type: " + string(new.Type))
|
||||
}
|
||||
return legacy, nil
|
||||
}
|
||||
|
||||
var endpointTypeAliases = map[EndpointType]EndpointType{
|
||||
"managed_wallet": EndpointTypeManagedWallet,
|
||||
"external_chain": EndpointTypeExternalChain,
|
||||
"card_token": EndpointTypeCardToken,
|
||||
"bank_account": EndpointTypeBankAccount,
|
||||
}
|
||||
|
||||
func normalizeEndpointType(t EndpointType) EndpointType {
|
||||
if canonical, ok := endpointTypeAliases[t]; ok {
|
||||
return canonical
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
7
api/edge/bff/interface/api/srequest/file.go
Normal file
7
api/edge/bff/interface/api/srequest/file.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package srequest
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
type FileUpload struct {
|
||||
ObjRef bson.ObjectID `json:"objRef"`
|
||||
}
|
||||
7
api/edge/bff/interface/api/srequest/invitation.go
Normal file
7
api/edge/bff/interface/api/srequest/invitation.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type CreateInvitation = model.Invitation
|
||||
54
api/edge/bff/interface/api/srequest/ledger.go
Normal file
54
api/edge/bff/interface/api/srequest/ledger.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/ledgerconv"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type LedgerAccountType string
|
||||
|
||||
const (
|
||||
LedgerAccountTypeUnspecified LedgerAccountType = "unspecified"
|
||||
LedgerAccountTypeAsset LedgerAccountType = "asset"
|
||||
LedgerAccountTypeLiability LedgerAccountType = "liability"
|
||||
LedgerAccountTypeRevenue LedgerAccountType = "revenue"
|
||||
LedgerAccountTypeExpense LedgerAccountType = "expense"
|
||||
)
|
||||
|
||||
type LedgerAccountStatus string
|
||||
|
||||
const (
|
||||
LedgerAccountStatusUnspecified LedgerAccountStatus = "unspecified"
|
||||
LedgerAccountStatusActive LedgerAccountStatus = "active"
|
||||
LedgerAccountStatusFrozen LedgerAccountStatus = "frozen"
|
||||
)
|
||||
|
||||
type CreateLedgerAccount struct {
|
||||
AccountType LedgerAccountType `json:"accountType"`
|
||||
Currency string `json:"currency"`
|
||||
AllowNegative bool `json:"allowNegative,omitempty"`
|
||||
Role account_role.AccountRole `json:"role"`
|
||||
Describable model.Describable `json:"describable"`
|
||||
OwnerRef *bson.ObjectID `json:"ownerRef,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (r *CreateLedgerAccount) Validate() error {
|
||||
if strings.TrimSpace(r.Currency) == "" {
|
||||
return merrors.InvalidArgument("currency is required", "currency")
|
||||
}
|
||||
if strings.TrimSpace(string(r.AccountType)) == "" || strings.EqualFold(string(r.AccountType), string(LedgerAccountTypeUnspecified)) {
|
||||
return merrors.InvalidArgument("accountType is required", "accountType")
|
||||
}
|
||||
if role := strings.TrimSpace(string(r.Role)); role != "" {
|
||||
if _, ok := ledgerconv.ParseAccountRole(role); !ok || ledgerconv.IsAccountRoleUnspecified(role) {
|
||||
return merrors.InvalidArgument("role is invalid", "role")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
8
api/edge/bff/interface/api/srequest/login.go
Normal file
8
api/edge/bff/interface/api/srequest/login.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package srequest
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
type Login struct {
|
||||
model.SessionIdentifier `json:",inline"`
|
||||
model.LoginData `json:"login"`
|
||||
}
|
||||
15
api/edge/bff/interface/api/srequest/password.go
Normal file
15
api/edge/bff/interface/api/srequest/password.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package srequest
|
||||
|
||||
type ChangePassword struct {
|
||||
Old string `json:"old"`
|
||||
New string `json:"new"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
}
|
||||
|
||||
type ResetPassword struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type ForgotPassword struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
126
api/edge/bff/interface/api/srequest/payment.go
Normal file
126
api/edge/bff/interface/api/srequest/payment.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
type PaymentBase struct {
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (b *PaymentBase) Validate() error {
|
||||
if b.IdempotencyKey == "" {
|
||||
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type QuotePayment struct {
|
||||
PaymentBase `json:",inline"`
|
||||
Intent PaymentIntent `json:"intent"`
|
||||
PreviewOnly bool `json:"previewOnly"`
|
||||
}
|
||||
|
||||
func (r *QuotePayment) Validate() error {
|
||||
if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// intent is mandatory, so validate always
|
||||
if err := r.Intent.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type QuotePayments struct {
|
||||
PaymentBase `json:",inline"`
|
||||
Intents []PaymentIntent `json:"intents"`
|
||||
PreviewOnly bool `json:"previewOnly"`
|
||||
}
|
||||
|
||||
func (r *QuotePayments) Validate() error {
|
||||
if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(r.Intents) == 0 {
|
||||
return merrors.InvalidArgument("intents are required", "intents")
|
||||
}
|
||||
for i := range r.Intents {
|
||||
if err := r.Intents[i].Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateQuoteIdempotency(previewOnly bool, idempotencyKey string) error {
|
||||
key := strings.TrimSpace(idempotencyKey)
|
||||
if previewOnly {
|
||||
if key != "" {
|
||||
return merrors.InvalidArgument("previewOnly requests must not include idempotencyKey", "idempotencyKey")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if key == "" {
|
||||
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type InitiatePayment struct {
|
||||
PaymentBase `json:",inline"`
|
||||
Intent *PaymentIntent `json:"intent,omitempty"`
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
}
|
||||
|
||||
func (r InitiatePayment) Validate() error {
|
||||
// base checks
|
||||
if err := r.PaymentBase.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasIntent := r.Intent != nil
|
||||
hasQuote := r.QuoteRef != ""
|
||||
|
||||
// must be exactly one
|
||||
switch {
|
||||
case !hasIntent && !hasQuote:
|
||||
return merrors.NoData("either intent or quoteRef must be provided")
|
||||
case hasIntent && hasQuote:
|
||||
return merrors.DataConflict("intent and quoteRef are mutually exclusive")
|
||||
}
|
||||
|
||||
// if intent provided → validate it
|
||||
if hasIntent {
|
||||
if err := r.Intent.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type InitiatePayments struct {
|
||||
PaymentBase `json:",inline"`
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
}
|
||||
|
||||
func (r *InitiatePayments) Validate() error {
|
||||
if r == nil {
|
||||
return merrors.InvalidArgument("request is required")
|
||||
}
|
||||
if err := r.PaymentBase.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.QuoteRef = strings.TrimSpace(r.QuoteRef)
|
||||
|
||||
if r.QuoteRef == "" {
|
||||
return merrors.InvalidArgument("quoteRef is required", "quoteRef")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
60
api/edge/bff/interface/api/srequest/payment_enums.go
Normal file
60
api/edge/bff/interface/api/srequest/payment_enums.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package srequest
|
||||
|
||||
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
|
||||
// Strings keep JSON readable; conversion helpers map these to proto enums.
|
||||
type PaymentKind string
|
||||
|
||||
const (
|
||||
PaymentKindUnspecified PaymentKind = "unspecified"
|
||||
PaymentKindPayout PaymentKind = "payout"
|
||||
PaymentKindInternalTransfer PaymentKind = "internal_transfer"
|
||||
PaymentKindFxConversion PaymentKind = "fx_conversion"
|
||||
)
|
||||
|
||||
// SettlementMode matches orchestrator settlement behavior.
|
||||
type SettlementMode string
|
||||
|
||||
const (
|
||||
SettlementModeUnspecified SettlementMode = "unspecified"
|
||||
SettlementModeFixSource SettlementMode = "fix_source"
|
||||
SettlementModeFixReceived SettlementMode = "fix_received"
|
||||
)
|
||||
|
||||
// FeeTreatment controls where fee impact is applied by quotation.
|
||||
type FeeTreatment string
|
||||
|
||||
const (
|
||||
FeeTreatmentUnspecified FeeTreatment = "unspecified"
|
||||
FeeTreatmentAddToSource FeeTreatment = "add_to_source"
|
||||
FeeTreatmentDeductFromDestination FeeTreatment = "deduct_from_destination"
|
||||
)
|
||||
|
||||
// FXSide mirrors the common FX side enum.
|
||||
type FXSide string
|
||||
|
||||
const (
|
||||
FXSideUnspecified FXSide = "unspecified"
|
||||
FXSideBuyBaseSellQuote FXSide = "buy_base_sell_quote"
|
||||
FXSideSellBaseBuyQuote FXSide = "sell_base_buy_quote"
|
||||
)
|
||||
|
||||
// ChainNetwork mirrors the chain network enum used by managed wallets.
|
||||
type ChainNetwork string
|
||||
|
||||
const (
|
||||
ChainNetworkUnspecified ChainNetwork = "unspecified"
|
||||
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
|
||||
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
|
||||
ChainNetworkTronMainnet ChainNetwork = "tron_mainnet"
|
||||
ChainNetworkTronNile ChainNetwork = "tron_nile"
|
||||
)
|
||||
|
||||
// InsufficientNetPolicy mirrors the fee engine policy override.
|
||||
type InsufficientNetPolicy string
|
||||
|
||||
const (
|
||||
InsufficientNetPolicyUnspecified InsufficientNetPolicy = "unspecified"
|
||||
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = "block_posting"
|
||||
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = "sweep_org_cash"
|
||||
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = "invoice_later"
|
||||
)
|
||||
56
api/edge/bff/interface/api/srequest/payment_intent.go
Normal file
56
api/edge/bff/interface/api/srequest/payment_intent.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
type PaymentIntent struct {
|
||||
Kind PaymentKind `json:"kind,omitempty"`
|
||||
Source *Endpoint `json:"source,omitempty"`
|
||||
Destination *Endpoint `json:"destination,omitempty"`
|
||||
Amount *paymenttypes.Money `json:"amount,omitempty"`
|
||||
FX *FXIntent `json:"fx,omitempty"`
|
||||
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
|
||||
FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
Customer *Customer `json:"customer,omitempty"`
|
||||
}
|
||||
|
||||
type AssetResolverStub struct{}
|
||||
|
||||
func (a *AssetResolverStub) IsSupported(_ string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *PaymentIntent) Validate() error {
|
||||
// Kind must be set (non-zero)
|
||||
var zeroKind PaymentKind
|
||||
if p.Kind == zeroKind {
|
||||
return merrors.InvalidArgument("kind is required", "intent.kind")
|
||||
}
|
||||
|
||||
if p.Source == nil {
|
||||
return merrors.InvalidArgument("source is required", "intent.source")
|
||||
}
|
||||
|
||||
if p.Destination == nil {
|
||||
return merrors.InvalidArgument("destination is required", "intent.destination")
|
||||
}
|
||||
|
||||
if p.Amount == nil {
|
||||
return merrors.InvalidArgument("amount is required", "intent.amount")
|
||||
}
|
||||
//TODO: collect supported currencies and validate against them
|
||||
if err := ValidateMoney(p.Amount, &AssetResolverStub{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.FX != nil {
|
||||
if err := p.FX.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func TestPaymentIntentValidate_AcceptsBaseIntentWithoutFX(t *testing.T) {
|
||||
intent := mustValidBaseIntent(t)
|
||||
|
||||
if err := intent.Validate(); err != nil {
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentIntentValidate_RejectsFXWithoutPair(t *testing.T) {
|
||||
intent := mustValidBaseIntent(t)
|
||||
intent.FX = &FXIntent{
|
||||
Side: FXSideSellBaseBuyQuote,
|
||||
}
|
||||
|
||||
if err := intent.Validate(); err == nil {
|
||||
t.Fatalf("expected validation error for missing fx pair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentIntentValidate_RejectsInvalidFXSide(t *testing.T) {
|
||||
intent := mustValidBaseIntent(t)
|
||||
intent.FX = &FXIntent{
|
||||
Pair: &CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "RUB",
|
||||
},
|
||||
Side: FXSide("wrong"),
|
||||
}
|
||||
|
||||
if err := intent.Validate(); err == nil {
|
||||
t.Fatalf("expected validation error for invalid fx side")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentIntentValidate_AcceptsValidFX(t *testing.T) {
|
||||
intent := mustValidBaseIntent(t)
|
||||
intent.FX = &FXIntent{
|
||||
Pair: &CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "RUB",
|
||||
},
|
||||
Side: FXSideSellBaseBuyQuote,
|
||||
}
|
||||
|
||||
if err := intent.Validate(); err != nil {
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustValidBaseIntent(t *testing.T) *PaymentIntent {
|
||||
t.Helper()
|
||||
|
||||
source, err := NewManagedWalletEndpointDTO(ManagedWalletEndpoint{ManagedWalletRef: "mw-src"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build source endpoint: %v", err)
|
||||
}
|
||||
destination, err := NewCardEndpointDTO(CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
FirstName: "Jane",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 2,
|
||||
ExpYear: 2030,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
return &PaymentIntent{
|
||||
Kind: PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &destination,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementMode: SettlementModeFixSource,
|
||||
FeeTreatment: FeeTreatmentAddToSource,
|
||||
}
|
||||
}
|
||||
427
api/edge/bff/interface/api/srequest/payment_types_test.go
Normal file
427
api/edge/bff/interface/api/srequest/payment_types_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
|
||||
meta := map[string]string{"note": "meta"}
|
||||
|
||||
t.Run("ledger", func(t *testing.T) {
|
||||
payload := LedgerEndpoint{LedgerAccountRef: "acc-1", ContraLedgerAccountRef: "contra-1"}
|
||||
endpoint, err := NewLedgerEndpointDTO(payload, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("build ledger endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeLedger {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
|
||||
}
|
||||
if string(endpoint.Data) != `{"ledger_account_ref":"acc-1","contra_ledger_account_ref":"contra-1"}` {
|
||||
t.Fatalf("unexpected data: %s", endpoint.Data)
|
||||
}
|
||||
decoded, err := endpoint.DecodeLedger()
|
||||
if err != nil {
|
||||
t.Fatalf("decode ledger: %v", err)
|
||||
}
|
||||
if decoded != payload {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
meta["note"] = "changed"
|
||||
if endpoint.Metadata["note"] != "meta" {
|
||||
t.Fatalf("metadata should be copied, got %s", endpoint.Metadata["note"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed wallet", func(t *testing.T) {
|
||||
payload := ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "mw-1",
|
||||
Asset: &Asset{
|
||||
Chain: ChainNetworkArbitrumOne,
|
||||
TokenSymbol: "USDC",
|
||||
ContractAddress: "0xabc",
|
||||
},
|
||||
}
|
||||
endpoint, err := NewManagedWalletEndpointDTO(payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build managed wallet endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeManagedWallet {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
|
||||
}
|
||||
decoded, err := endpoint.DecodeManagedWallet()
|
||||
if err != nil {
|
||||
t.Fatalf("decode managed wallet: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, payload) {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("external chain", func(t *testing.T) {
|
||||
payload := ExternalChainEndpoint{
|
||||
Asset: &Asset{
|
||||
Chain: ChainNetworkEthereumMainnet,
|
||||
TokenSymbol: "ETH",
|
||||
},
|
||||
Address: "0x123",
|
||||
Memo: "memo",
|
||||
}
|
||||
endpoint, err := NewExternalChainEndpointDTO(payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build external chain endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeExternalChain {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeExternalChain, endpoint.Type)
|
||||
}
|
||||
decoded, err := endpoint.DecodeExternalChain()
|
||||
if err != nil {
|
||||
t.Fatalf("decode external chain: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, payload) {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card", func(t *testing.T) {
|
||||
payload := CardEndpoint{
|
||||
Pan: "pan",
|
||||
FirstName: "Jane",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Country: "US",
|
||||
}
|
||||
endpoint, err := NewCardEndpointDTO(payload, map[string]string{"k": "v"})
|
||||
if err != nil {
|
||||
t.Fatalf("build card endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeCard {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeCard, endpoint.Type)
|
||||
}
|
||||
decoded, err := endpoint.DecodeCard()
|
||||
if err != nil {
|
||||
t.Fatalf("decode card: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, payload) {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
if endpoint.Metadata["k"] != "v" {
|
||||
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["k"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card token", func(t *testing.T) {
|
||||
payload := CardTokenEndpoint{Token: "token", MaskedPan: "****1234"}
|
||||
endpoint, err := NewCardTokenEndpointDTO(payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build card token endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeCardToken {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeCardToken, endpoint.Type)
|
||||
}
|
||||
decoded, err := endpoint.DecodeCardToken()
|
||||
if err != nil {
|
||||
t.Fatalf("decode card token: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, payload) {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wallet", func(t *testing.T) {
|
||||
payload := WalletEndpoint{WalletID: "wallet-1"}
|
||||
endpoint, err := NewWalletEndpointDTO(payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build wallet endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeWallet {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeWallet, endpoint.Type)
|
||||
}
|
||||
decoded, err := endpoint.DecodeWallet()
|
||||
if err != nil {
|
||||
t.Fatalf("decode wallet: %v", err)
|
||||
}
|
||||
if decoded != payload {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bank account", func(t *testing.T) {
|
||||
payload := BankAccountEndpoint{
|
||||
RecipientName: "ACME",
|
||||
Inn: "inn",
|
||||
Kpp: "kpp",
|
||||
BankName: "bank",
|
||||
Bik: "bik",
|
||||
AccountNumber: "123",
|
||||
CorrespondentAccount: "456",
|
||||
}
|
||||
endpoint, err := NewBankAccountEndpointDTO(payload, map[string]string{"note": "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("build bank account endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeBankAccount {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeBankAccount, endpoint.Type)
|
||||
}
|
||||
decoded, err := endpoint.DecodeBankAccount()
|
||||
if err != nil {
|
||||
t.Fatalf("decode bank account: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, payload) {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
if endpoint.Metadata["note"] != "n" {
|
||||
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["note"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("iban", func(t *testing.T) {
|
||||
payload := IBANEndpoint{
|
||||
IBAN: "DE123",
|
||||
AccountHolder: "John Doe",
|
||||
BIC: "BICCODE",
|
||||
BankName: "BankName",
|
||||
}
|
||||
endpoint, err := NewIBANEndpointDTO(payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build iban endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeIBAN {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeIBAN, endpoint.Type)
|
||||
}
|
||||
decoded, err := endpoint.DecodeIBAN()
|
||||
if err != nil {
|
||||
t.Fatalf("decode iban: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, payload) {
|
||||
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type mismatch", func(t *testing.T) {
|
||||
endpoint, err := NewLedgerEndpointDTO(LedgerEndpoint{LedgerAccountRef: "acc"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build ledger endpoint: %v", err)
|
||||
}
|
||||
if _, err := endpoint.DecodeCard(); err == nil || !strings.Contains(err.Error(), "expected endpoint type") {
|
||||
t.Fatalf("expected type mismatch error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json data", func(t *testing.T) {
|
||||
endpoint := Endpoint{Type: EndpointTypeLedger, Data: json.RawMessage("not-json")}
|
||||
if _, err := endpoint.DecodeLedger(); err == nil {
|
||||
t.Fatalf("expected decode error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy type alias normalizes", func(t *testing.T) {
|
||||
raw := []byte(`{"type":"managed_wallet","data":{"managed_wallet_ref":"mw-legacy"}}`)
|
||||
var endpoint Endpoint
|
||||
if err := json.Unmarshal(raw, &endpoint); err != nil {
|
||||
t.Fatalf("unmarshal with legacy type: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeManagedWallet {
|
||||
t.Fatalf("expected normalized type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
|
||||
}
|
||||
payload, err := endpoint.DecodeManagedWallet()
|
||||
if err != nil {
|
||||
t.Fatalf("decode managed wallet with alias: %v", err)
|
||||
}
|
||||
if payload.ManagedWalletRef != "mw-legacy" {
|
||||
t.Fatalf("decoded payload mismatch from alias: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPaymentIntentJSONRoundTrip(t *testing.T) {
|
||||
sourcePayload := LedgerEndpoint{LedgerAccountRef: "source"}
|
||||
source, err := NewLedgerEndpointDTO(sourcePayload, map[string]string{"src": "meta"})
|
||||
if err != nil {
|
||||
t.Fatalf("build source endpoint: %v", err)
|
||||
}
|
||||
destPayload := ExternalChainEndpoint{Address: "0xabc", Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "USDC"}}
|
||||
dest, err := NewExternalChainEndpointDTO(destPayload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &PaymentIntent{
|
||||
Kind: PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &dest,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
|
||||
FX: &FXIntent{
|
||||
Pair: &CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: FXSideBuyBaseSellQuote,
|
||||
Firm: true,
|
||||
TTLms: 5000,
|
||||
PreferredProvider: "provider",
|
||||
MaxAgeMs: 10,
|
||||
},
|
||||
SettlementMode: SettlementModeFixReceived,
|
||||
Attributes: map[string]string{"k": "v"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal intent: %v", err)
|
||||
}
|
||||
var decoded PaymentIntent
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal intent: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Kind != intent.Kind || decoded.SettlementMode != intent.SettlementMode {
|
||||
t.Fatalf("scalar fields changed after round trip")
|
||||
}
|
||||
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
|
||||
t.Fatalf("amount mismatch after round trip")
|
||||
}
|
||||
if decoded.FX == nil || decoded.FX.PreferredProvider != intent.FX.PreferredProvider {
|
||||
t.Fatalf("fx mismatch after round trip")
|
||||
}
|
||||
if decoded.Source == nil || decoded.Destination == nil {
|
||||
t.Fatalf("source/destination missing after round trip")
|
||||
}
|
||||
sourceDecoded, err := decoded.Source.DecodeLedger()
|
||||
if err != nil {
|
||||
t.Fatalf("decode source after round trip: %v", err)
|
||||
}
|
||||
if sourceDecoded != sourcePayload {
|
||||
t.Fatalf("source payload mismatch after round trip: %#v vs %#v", sourceDecoded, sourcePayload)
|
||||
}
|
||||
destDecoded, err := decoded.Destination.DecodeExternalChain()
|
||||
if err != nil {
|
||||
t.Fatalf("decode destination after round trip: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(destDecoded, destPayload) {
|
||||
t.Fatalf("destination payload mismatch after round trip: %#v vs %#v", destDecoded, destPayload)
|
||||
}
|
||||
if decoded.Attributes["k"] != "v" {
|
||||
t.Fatalf("attributes mismatch after round trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
|
||||
sourcePayload := ManagedWalletEndpoint{ManagedWalletRef: "mw"}
|
||||
source, err := NewManagedWalletEndpointDTO(sourcePayload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build source endpoint: %v", err)
|
||||
}
|
||||
destPayload := LedgerEndpoint{LedgerAccountRef: "dest-ledger"}
|
||||
dest, err := NewLedgerEndpointDTO(destPayload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &PaymentIntent{
|
||||
Kind: PaymentKindInternalTransfer,
|
||||
Source: &source,
|
||||
Destination: &dest,
|
||||
Amount: &paymenttypes.Money{Amount: "1", Currency: "USD"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal intent: %v", err)
|
||||
}
|
||||
var decoded PaymentIntent
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal intent: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Kind != intent.Kind || decoded.FX != nil {
|
||||
t.Fatalf("unexpected fx data in minimal intent: %#v", decoded)
|
||||
}
|
||||
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
|
||||
t.Fatalf("amount mismatch after round trip")
|
||||
}
|
||||
if decoded.Source == nil || decoded.Destination == nil {
|
||||
t.Fatalf("endpoints missing after round trip")
|
||||
}
|
||||
sourceDecoded, err := decoded.Source.DecodeManagedWallet()
|
||||
if err != nil {
|
||||
t.Fatalf("decode source: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(sourceDecoded, sourcePayload) {
|
||||
t.Fatalf("source payload mismatch: %#v vs %#v", sourceDecoded, sourcePayload)
|
||||
}
|
||||
destDecoded, err := decoded.Destination.DecodeLedger()
|
||||
if err != nil {
|
||||
t.Fatalf("decode destination: %v", err)
|
||||
}
|
||||
if destDecoded != destPayload {
|
||||
t.Fatalf("destination payload mismatch: %#v vs %#v", destDecoded, destPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyEndpointRoundTrip(t *testing.T) {
|
||||
legacy := &LegacyPaymentEndpoint{
|
||||
ExternalChain: &ExternalChainEndpoint{
|
||||
Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "DAI", ContractAddress: "0xdef"},
|
||||
Address: "0x123",
|
||||
Memo: "memo",
|
||||
},
|
||||
Metadata: map[string]string{"note": "legacy"},
|
||||
}
|
||||
|
||||
endpoint, err := LegacyPaymentEndpointToEndpointDTO(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("convert legacy to dto: %v", err)
|
||||
}
|
||||
if endpoint == nil || endpoint.Type != EndpointTypeExternalChain {
|
||||
t.Fatalf("unexpected endpoint result: %#v", endpoint)
|
||||
}
|
||||
legacy.Metadata["note"] = "changed"
|
||||
if endpoint.Metadata["note"] != "legacy" {
|
||||
t.Fatalf("metadata should be copied from legacy")
|
||||
}
|
||||
|
||||
roundTrip, err := EndpointDTOToLegacyPaymentEndpoint(endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("convert dto back to legacy: %v", err)
|
||||
}
|
||||
if roundTrip == nil || roundTrip.ExternalChain == nil {
|
||||
t.Fatalf("round trip legacy missing payload: %#v", roundTrip)
|
||||
}
|
||||
if !reflect.DeepEqual(roundTrip.ExternalChain, legacy.ExternalChain) {
|
||||
t.Fatalf("round trip payload mismatch: %#v vs %#v", roundTrip.ExternalChain, legacy.ExternalChain)
|
||||
}
|
||||
if roundTrip.Metadata["note"] != "legacy" {
|
||||
t.Fatalf("metadata mismatch after round trip: %v", roundTrip.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyEndpointConversionRejectsMultiple(t *testing.T) {
|
||||
_, err := LegacyPaymentEndpointToEndpointDTO(&LegacyPaymentEndpoint{
|
||||
Ledger: &LedgerEndpoint{LedgerAccountRef: "a"},
|
||||
Card: &CardEndpoint{Pan: "t"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when multiple legacy endpoints are set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointUnmarshalLegacyShape(t *testing.T) {
|
||||
raw := []byte(`{"ledger":{"ledger_account_ref":"abc"}}`)
|
||||
var endpoint Endpoint
|
||||
if err := json.Unmarshal(raw, &endpoint); err != nil {
|
||||
t.Fatalf("unmarshal legacy shape: %v", err)
|
||||
}
|
||||
if endpoint.Type != EndpointTypeLedger {
|
||||
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
|
||||
}
|
||||
payload, err := endpoint.DecodeLedger()
|
||||
if err != nil {
|
||||
t.Fatalf("decode ledger from legacy shape: %v", err)
|
||||
}
|
||||
if payload.LedgerAccountRef != "abc" {
|
||||
t.Fatalf("unexpected payload from legacy shape: %#v", payload)
|
||||
}
|
||||
}
|
||||
53
api/edge/bff/interface/api/srequest/payment_validate_test.go
Normal file
53
api/edge/bff/interface/api/srequest/payment_validate_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package srequest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateQuoteIdempotency(t *testing.T) {
|
||||
t.Run("non-preview requires idempotency key", func(t *testing.T) {
|
||||
if err := validateQuoteIdempotency(false, ""); err == nil {
|
||||
t.Fatalf("expected error for empty idempotency key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preview rejects idempotency key", func(t *testing.T) {
|
||||
if err := validateQuoteIdempotency(true, "idem-1"); err == nil {
|
||||
t.Fatalf("expected error when preview request has idempotency key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preview accepts empty idempotency key", func(t *testing.T) {
|
||||
if err := validateQuoteIdempotency(true, ""); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-preview accepts idempotency key", func(t *testing.T) {
|
||||
if err := validateQuoteIdempotency(false, "idem-1"); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsValidate(t *testing.T) {
|
||||
t.Run("accepts quoteRef", func(t *testing.T) {
|
||||
req := &InitiatePayments{
|
||||
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
||||
QuoteRef: " quote-1 ",
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got, want := req.QuoteRef, "quote-1"; got != want {
|
||||
t.Fatalf("quoteRef mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing quoteRef", func(t *testing.T) {
|
||||
req := &InitiatePayments{
|
||||
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
||||
}
|
||||
if err := req.Validate(); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
133
api/edge/bff/interface/api/srequest/payment_value_objects.go
Normal file
133
api/edge/bff/interface/api/srequest/payment_value_objects.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
// AssetResolver defines environment-specific supported assets.
|
||||
// Implementations should check:
|
||||
// - fiat assets (ISO-4217)
|
||||
// - crypto assets supported by gateways / FX providers
|
||||
type AssetResolver interface {
|
||||
IsSupported(ticker string) bool
|
||||
}
|
||||
|
||||
// Precompile regex for efficiency.
|
||||
var currencySyntax = regexp.MustCompile(`^[A-Z0-9]{2,10}$`)
|
||||
|
||||
// ValidateCurrency validates currency syntax and checks dictionary via assetResolver.
|
||||
func ValidateCurrency(cur string, assetResolver AssetResolver) error {
|
||||
// Basic presence
|
||||
if strings.TrimSpace(cur) == "" {
|
||||
return merrors.InvalidArgument("currency is required", "intent.currency")
|
||||
}
|
||||
|
||||
// Normalize
|
||||
cur = strings.ToUpper(strings.TrimSpace(cur))
|
||||
|
||||
// Syntax check
|
||||
if !currencySyntax.MatchString(cur) {
|
||||
return merrors.InvalidArgument(
|
||||
"invalid currency format (must be A–Z0–9, length 2–10)",
|
||||
"intent.currency",
|
||||
)
|
||||
}
|
||||
|
||||
// Dictionary validation
|
||||
if assetResolver == nil {
|
||||
return merrors.InvalidArgument("asset resolver is not configured", "intent.currency")
|
||||
}
|
||||
|
||||
if !assetResolver.IsSupported(cur) {
|
||||
return merrors.InvalidArgument("unsupported currency/asset", "intent.currency")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateMoney(m *paymenttypes.Money, assetResolver AssetResolver) error {
|
||||
if m == nil {
|
||||
return merrors.InvalidArgument("money is required", "intent.amount")
|
||||
}
|
||||
|
||||
// 1) Basic presence
|
||||
if strings.TrimSpace(m.Amount) == "" {
|
||||
return merrors.InvalidArgument("amount is required", "intent.amount")
|
||||
}
|
||||
|
||||
// 2) Validate decimal amount
|
||||
amount, err := decimal.NewFromString(m.Amount)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgument("invalid decimal amount", "intent.amount")
|
||||
}
|
||||
if amount.IsNegative() {
|
||||
return merrors.InvalidArgument("amount must be >= 0", "intent.amount")
|
||||
}
|
||||
|
||||
// 3) Validate currency via helper
|
||||
if err := ValidateCurrency(m.Currency, assetResolver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CurrencyPair struct {
|
||||
Base string `json:"base"`
|
||||
Quote string `json:"quote"`
|
||||
}
|
||||
|
||||
func (p *CurrencyPair) Validate() error {
|
||||
if p == nil {
|
||||
return merrors.InvalidArgument("currency pair is required", "currncy_pair")
|
||||
}
|
||||
if err := ValidateCurrency(p.Base, &AssetResolverStub{}); err != nil {
|
||||
return merrors.InvalidArgument("invalid base currency in pair: "+err.Error(), "currency_pair.base")
|
||||
}
|
||||
if err := ValidateCurrency(p.Quote, &AssetResolverStub{}); err != nil {
|
||||
return merrors.InvalidArgument("invalid quote currency in pair: "+err.Error(), "currency_pair.quote")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FXIntent struct {
|
||||
Pair *CurrencyPair `json:"pair,omitempty"`
|
||||
Side FXSide `json:"side,omitempty"`
|
||||
Firm bool `json:"firm,omitempty"`
|
||||
TTLms int64 `json:"ttl_ms,omitempty"`
|
||||
PreferredProvider string `json:"preferred_provider,omitempty"`
|
||||
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (fx *FXIntent) Validate() error {
|
||||
if fx.Pair == nil {
|
||||
return merrors.InvalidArgument("fx pair is required", "intent.fx.pair")
|
||||
}
|
||||
if err := fx.Pair.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(string(fx.Side)) {
|
||||
case string(FXSideBuyBaseSellQuote), string(FXSideSellBaseBuyQuote):
|
||||
default:
|
||||
return merrors.InvalidArgument("fx side is invalid", "intent.fx.side")
|
||||
}
|
||||
|
||||
if fx.TTLms < 0 {
|
||||
return merrors.InvalidArgument("fx ttl_ms cannot be negative", "intent.fx.ttl_ms")
|
||||
}
|
||||
if fx.TTLms == 0 && fx.Firm {
|
||||
return merrors.InvalidArgument("firm quote requires positive ttl_ms", "intent.fx.ttl_ms")
|
||||
}
|
||||
|
||||
if fx.MaxAgeMs < 0 {
|
||||
return merrors.InvalidArgument("fx max_age_ms cannot be negative", "intent.fx.max_age_ms")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
5
api/edge/bff/interface/api/srequest/refresh.go
Normal file
5
api/edge/bff/interface/api/srequest/refresh.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
type AccessTokenRefresh = model.ClientRefreshToken
|
||||
19
api/edge/bff/interface/api/srequest/reorder.go
Normal file
19
api/edge/bff/interface/api/srequest/reorder.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package srequest
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
type Reorder struct {
|
||||
ParentRef bson.ObjectID `json:"parentRef"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
}
|
||||
|
||||
type ReorderX struct {
|
||||
ObjectRef bson.ObjectID `json:"objectRef"`
|
||||
To int `json:"to"`
|
||||
}
|
||||
|
||||
type ReorderXDefault struct {
|
||||
ReorderX `json:",inline"`
|
||||
ParentRef bson.ObjectID `json:"parentRef"`
|
||||
}
|
||||
5
api/edge/bff/interface/api/srequest/rotate.go
Normal file
5
api/edge/bff/interface/api/srequest/rotate.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
type TokenRefreshRotate = model.ClientRefreshToken
|
||||
13
api/edge/bff/interface/api/srequest/sgchange.go
Normal file
13
api/edge/bff/interface/api/srequest/sgchange.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package srequest
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
type GroupItemChange struct {
|
||||
GroupRef bson.ObjectID `json:"groupRef"`
|
||||
ItemRef bson.ObjectID `json:"itemRef"`
|
||||
}
|
||||
|
||||
type RemoveItemFromGroup struct {
|
||||
GroupItemChange `json:",inline"`
|
||||
TargetItemRef bson.ObjectID `json:"targetItemRef"`
|
||||
}
|
||||
31
api/edge/bff/interface/api/srequest/signup.go
Normal file
31
api/edge/bff/interface/api/srequest/signup.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type Signup struct {
|
||||
Account model.AccountData `json:"account"`
|
||||
Organization model.Describable `json:"organization"`
|
||||
OrganizationTimeZone string `json:"organizationTimeZone"`
|
||||
OwnerRole model.Describable `json:"ownerRole"`
|
||||
CryptoWallet model.Describable `json:"cryptoWallet"`
|
||||
LedgerWallet model.Describable `json:"ledgerWallet"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON enforces strict parsing to catch malformed or unexpected fields.
|
||||
func (s *Signup) UnmarshalJSON(data []byte) error {
|
||||
type alias Signup
|
||||
var payload alias
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = Signup(payload)
|
||||
return nil
|
||||
}
|
||||
150
api/edge/bff/interface/api/srequest/signup_test.go
Normal file
150
api/edge/bff/interface/api/srequest/signup_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package srequest_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
)
|
||||
|
||||
func TestSignupRequest_JSONSerialization(t *testing.T) {
|
||||
signup := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
Organization: model.Describable{
|
||||
Name: "Test Organization",
|
||||
},
|
||||
OrganizationTimeZone: "UTC",
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
}
|
||||
|
||||
// Test JSON marshaling
|
||||
jsonData, err := json.Marshal(signup)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, jsonData)
|
||||
|
||||
// Test JSON unmarshaling
|
||||
var unmarshaled srequest.Signup
|
||||
err = json.Unmarshal(jsonData, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all fields are properly serialized/deserialized
|
||||
assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name)
|
||||
assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login)
|
||||
assert.Equal(t, signup.Account.Password, unmarshaled.Account.Password)
|
||||
assert.Equal(t, signup.Organization.Name, unmarshaled.Organization.Name)
|
||||
assert.Equal(t, signup.OrganizationTimeZone, unmarshaled.OrganizationTimeZone)
|
||||
assert.Equal(t, signup.OwnerRole.Name, unmarshaled.OwnerRole.Name)
|
||||
}
|
||||
|
||||
func TestSignupRequest_MinimalValidRequest(t *testing.T) {
|
||||
signup := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
Organization: model.Describable{
|
||||
Name: "Test Organization",
|
||||
},
|
||||
OrganizationTimeZone: "UTC",
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
}
|
||||
|
||||
// Test JSON marshaling
|
||||
jsonData, err := json.Marshal(signup)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, jsonData)
|
||||
|
||||
// Test JSON unmarshaling
|
||||
var unmarshaled srequest.Signup
|
||||
err = json.Unmarshal(jsonData, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify minimal request is valid
|
||||
assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name)
|
||||
assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login)
|
||||
assert.Equal(t, signup.Organization.Name, unmarshaled.Organization.Name)
|
||||
}
|
||||
|
||||
func TestSignupRequest_InvalidJSON(t *testing.T) {
|
||||
invalidJSONs := []string{
|
||||
`{"account": invalid}`,
|
||||
`{"organization": 123}`,
|
||||
`{"organizationTimeZone": true}`,
|
||||
`{"defaultPriorityGroup": "not_an_object"}`,
|
||||
`{"anonymousUser": []}`,
|
||||
`{"anonymousRole": 456}`,
|
||||
`{invalid json}`,
|
||||
}
|
||||
|
||||
for i, invalidJSON := range invalidJSONs {
|
||||
t.Run(fmt.Sprintf("Invalid JSON %d", i), func(t *testing.T) {
|
||||
var signup srequest.Signup
|
||||
err := json.Unmarshal([]byte(invalidJSON), &signup)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupRequest_UnicodeCharacters(t *testing.T) {
|
||||
signup := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "测试@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: "Test 用户 Üser",
|
||||
},
|
||||
},
|
||||
Organization: model.Describable{
|
||||
Name: "测试 Organization",
|
||||
},
|
||||
OrganizationTimeZone: "UTC",
|
||||
OwnerRole: model.Describable{
|
||||
Name: "所有者",
|
||||
},
|
||||
}
|
||||
|
||||
// Test JSON marshaling
|
||||
jsonData, err := json.Marshal(signup)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, jsonData)
|
||||
|
||||
// Test JSON unmarshaling
|
||||
var unmarshaled srequest.Signup
|
||||
err = json.Unmarshal(jsonData, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify unicode characters are properly handled
|
||||
assert.Equal(t, "测试@example.com", unmarshaled.Account.Login)
|
||||
assert.Equal(t, "Test 用户 Üser", unmarshaled.Account.Name)
|
||||
assert.Equal(t, "测试 Organization", unmarshaled.Organization.Name)
|
||||
assert.Equal(t, "所有者", unmarshaled.OwnerRole.Name)
|
||||
}
|
||||
20
api/edge/bff/interface/api/srequest/taggable.go
Normal file
20
api/edge/bff/interface/api/srequest/taggable.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package srequest
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
// TaggableSingle is used for single tag operations (add/remove tag)
|
||||
type TaggableSingle struct {
|
||||
ObjectRef bson.ObjectID `json:"objectRef"`
|
||||
TagRef bson.ObjectID `json:"tagRef"`
|
||||
}
|
||||
|
||||
// TaggableMultiple is used for multiple tag operations (add tags, set tags)
|
||||
type TaggableMultiple struct {
|
||||
ObjectRef bson.ObjectID `json:"objectRef"`
|
||||
TagRefs []bson.ObjectID `json:"tagRefs"`
|
||||
}
|
||||
|
||||
// TaggableObject is used for object-only operations (remove all tags, get tags)
|
||||
type TaggableObject struct {
|
||||
ObjectRef bson.ObjectID `json:"objectRef"`
|
||||
}
|
||||
5
api/edge/bff/interface/api/srequest/validateable.go
Normal file
5
api/edge/bff/interface/api/srequest/validateable.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
type Validatable interface {
|
||||
Validate() error
|
||||
}
|
||||
12
api/edge/bff/interface/api/srequest/wallet.go
Normal file
12
api/edge/bff/interface/api/srequest/wallet.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type CreateWallet struct {
|
||||
Description model.Describable `json:"description"`
|
||||
Asset model.ChainAssetKey `json:"asset"`
|
||||
OwnerRef *bson.ObjectID `json:"ownerRef,omitempty"`
|
||||
}
|
||||
62
api/edge/bff/interface/api/sresponse/account.go
Normal file
62
api/edge/bff/interface/api/sresponse/account.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type accountData struct {
|
||||
model.AccountPublic `json:",inline"`
|
||||
IsAnonymous bool `json:"isAnonymous"`
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Account accountData `json:"account"`
|
||||
}
|
||||
|
||||
func _createAccount(account *model.Account, isAnonymous bool) *accountData {
|
||||
return &accountData{
|
||||
AccountPublic: account.AccountPublic,
|
||||
IsAnonymous: isAnonymous,
|
||||
}
|
||||
}
|
||||
|
||||
func _toAccount(account *model.Account, orgRef bson.ObjectID) *accountData {
|
||||
return _createAccount(account, model.AccountIsAnonymous(&account.UserDataBase, orgRef))
|
||||
}
|
||||
|
||||
func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(
|
||||
logger,
|
||||
&accountResponse{
|
||||
Account: *_createAccount(account, false),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
type accountsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Accounts []accountData `json:"accounts"`
|
||||
}
|
||||
|
||||
func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc {
|
||||
// Convert each account to its public representation.
|
||||
publicAccounts := make([]accountData, len(accounts))
|
||||
for i, a := range accounts {
|
||||
publicAccounts[i] = *_toAccount(&a, orgRef)
|
||||
}
|
||||
|
||||
return response.Ok(
|
||||
logger,
|
||||
&accountsResponse{
|
||||
Accounts: publicAccounts,
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
)
|
||||
}
|
||||
5
api/edge/bff/interface/api/sresponse/authresp.go
Normal file
5
api/edge/bff/interface/api/sresponse/authresp.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package sresponse
|
||||
|
||||
type authResponse struct {
|
||||
AccessToken TokenData `json:"accessToken"`
|
||||
}
|
||||
15
api/edge/bff/interface/api/sresponse/badpassword.go
Normal file
15
api/edge/bff/interface/api/sresponse/badpassword.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func BadRPassword(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
||||
logger.Info("Failed password validation check", zap.Error(err))
|
||||
return response.BadRequest(logger, source, "invalid_request", err.Error())
|
||||
}
|
||||
24
api/edge/bff/interface/api/sresponse/dzone.go
Normal file
24
api/edge/bff/interface/api/sresponse/dzone.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type dzoneResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
DZone model.DZone `json:"dzone"`
|
||||
}
|
||||
|
||||
func DZone(logger mlogger.Logger, dzone *model.DZone, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(
|
||||
logger,
|
||||
&dzoneResponse{
|
||||
DZone: *dzone,
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
)
|
||||
}
|
||||
16
api/edge/bff/interface/api/sresponse/file.go
Normal file
16
api/edge/bff/interface/api/sresponse/file.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type fileUpladed struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func FileUploaded(logger mlogger.Logger, url string) http.HandlerFunc {
|
||||
return response.Ok(logger, &fileUpladed{URL: url})
|
||||
}
|
||||
21
api/edge/bff/interface/api/sresponse/invitation.go
Normal file
21
api/edge/bff/interface/api/sresponse/invitation.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type invitationResp struct {
|
||||
Invitation model.PublicInvitation `json:"invitation"`
|
||||
}
|
||||
|
||||
func Invitation(logger mlogger.Logger, invitation *model.PublicInvitation) http.HandlerFunc {
|
||||
return response.Ok(logger, &invitationResp{Invitation: *invitation})
|
||||
}
|
||||
|
||||
func Invitations(logger mlogger.Logger, invitations []model.Invitation) http.HandlerFunc {
|
||||
return response.Ok(logger, invitations)
|
||||
}
|
||||
126
api/edge/bff/interface/api/sresponse/ledger.go
Normal file
126
api/edge/bff/interface/api/sresponse/ledger.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
)
|
||||
|
||||
type ledgerAccount struct {
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
LedgerAccountRef string `json:"ledgerAccountRef"`
|
||||
OrganizationRef string `json:"organizationRef"`
|
||||
OwnerRef string `json:"ownerRef,omitempty"`
|
||||
AccountCode string `json:"accountCode"`
|
||||
AccountType string `json:"accountType"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
AllowNegative bool `json:"allowNegative"`
|
||||
Role string `json:"role"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ledgerAccountsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Accounts []ledgerAccount `json:"accounts"`
|
||||
}
|
||||
|
||||
type ledgerAccountResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Account ledgerAccount `json:"account"`
|
||||
}
|
||||
|
||||
type ledgerMoney struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type ledgerBalance struct {
|
||||
LedgerAccountRef string `json:"ledgerAccountRef"`
|
||||
Balance *ledgerMoney `json:"balance,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
LastUpdated time.Time `json:"lastUpdated,omitempty"`
|
||||
}
|
||||
|
||||
type ledgerBalanceResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Balance ledgerBalance `json:"balance"`
|
||||
}
|
||||
|
||||
func LedgerAccounts(logger mlogger.Logger, accounts []*ledgerv1.LedgerAccount, accessToken *TokenData) http.HandlerFunc {
|
||||
dto := make([]ledgerAccount, 0, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
dto = append(dto, toLedgerAccount(acc))
|
||||
}
|
||||
return response.Ok(logger, ledgerAccountsResponse{
|
||||
Accounts: dto,
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
func LedgerAccountCreated(logger mlogger.Logger, account *ledgerv1.LedgerAccount, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Created(logger, ledgerAccountResponse{
|
||||
Account: toLedgerAccount(account),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
func LedgerBalance(logger mlogger.Logger, resp *ledgerv1.BalanceResponse, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, ledgerBalanceResponse{
|
||||
Balance: toLedgerBalance(resp),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
func toLedgerAccount(acc *ledgerv1.LedgerAccount) ledgerAccount {
|
||||
if acc == nil {
|
||||
return ledgerAccount{}
|
||||
}
|
||||
return ledgerAccount{
|
||||
Describable: model.Describable{
|
||||
Name: acc.GetDescribable().GetName(),
|
||||
Description: acc.GetDescribable().Description,
|
||||
},
|
||||
LedgerAccountRef: acc.GetLedgerAccountRef(),
|
||||
OrganizationRef: acc.GetOrganizationRef(),
|
||||
OwnerRef: acc.GetOwnerRef(),
|
||||
AccountCode: acc.GetAccountCode(),
|
||||
AccountType: acc.GetAccountType().String(),
|
||||
Currency: acc.GetCurrency(),
|
||||
Status: acc.GetStatus().String(),
|
||||
AllowNegative: acc.GetAllowNegative(),
|
||||
Role: acc.GetRole().String(),
|
||||
Metadata: acc.GetMetadata(),
|
||||
CreatedAt: acc.GetCreatedAt().AsTime(),
|
||||
UpdatedAt: acc.GetUpdatedAt().AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func toLedgerBalance(resp *ledgerv1.BalanceResponse) ledgerBalance {
|
||||
if resp == nil {
|
||||
return ledgerBalance{}
|
||||
}
|
||||
return ledgerBalance{
|
||||
LedgerAccountRef: resp.GetLedgerAccountRef(),
|
||||
Balance: toLedgerMoney(resp.GetBalance()),
|
||||
Version: resp.GetVersion(),
|
||||
LastUpdated: resp.GetLastUpdated().AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func toLedgerMoney(m *moneyv1.Money) *ledgerMoney {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &ledgerMoney{
|
||||
Amount: m.GetAmount(),
|
||||
Currency: m.GetCurrency(),
|
||||
}
|
||||
}
|
||||
27
api/edge/bff/interface/api/sresponse/login.go
Normal file
27
api/edge/bff/interface/api/sresponse/login.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type loginResponse struct {
|
||||
accountResponse
|
||||
RefreshToken TokenData `json:"refreshToken"`
|
||||
}
|
||||
|
||||
func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(
|
||||
logger,
|
||||
&loginResponse{
|
||||
accountResponse: accountResponse{
|
||||
Account: *_createAccount(account, false),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
RefreshToken: *refreshToken,
|
||||
},
|
||||
)
|
||||
}
|
||||
29
api/edge/bff/interface/api/sresponse/login_pending.go
Normal file
29
api/edge/bff/interface/api/sresponse/login_pending.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type pendingLoginResponse struct {
|
||||
Account accountResponse `json:"account"`
|
||||
PendingToken TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc {
|
||||
return response.Accepted(
|
||||
logger,
|
||||
&pendingLoginResponse{
|
||||
Account: accountResponse{
|
||||
Account: *_createAccount(account, false),
|
||||
authResponse: authResponse{},
|
||||
},
|
||||
PendingToken: *pendingToken,
|
||||
Target: target,
|
||||
},
|
||||
)
|
||||
}
|
||||
16
api/edge/bff/interface/api/sresponse/money.go
Normal file
16
api/edge/bff/interface/api/sresponse/money.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func toMoney(m *moneyv1.Money) *paymenttypes.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: m.GetAmount(),
|
||||
Currency: m.GetCurrency(),
|
||||
}
|
||||
}
|
||||
49
api/edge/bff/interface/api/sresponse/objects.go
Normal file
49
api/edge/bff/interface/api/sresponse/objects.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
)
|
||||
|
||||
type DynamicResponse[T any] struct {
|
||||
authResponse `json:",inline"`
|
||||
Items []T
|
||||
// FieldName is the JSON key to use for the items.
|
||||
FieldName string
|
||||
}
|
||||
|
||||
func (dr DynamicResponse[T]) MarshalJSON() ([]byte, error) {
|
||||
// Create a temporary map to hold the keys and values.
|
||||
m := map[string]any{
|
||||
dr.FieldName: dr.Items,
|
||||
"accessToken": dr.AccessToken,
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
type handler = func(logger mlogger.Logger, data any) http.HandlerFunc
|
||||
|
||||
func objectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type, handler handler) http.HandlerFunc {
|
||||
resp := &DynamicResponse[T]{
|
||||
Items: items,
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
FieldName: resource,
|
||||
}
|
||||
return handler(logger, resp)
|
||||
}
|
||||
|
||||
func ObjectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
|
||||
return objectsAuth(logger, items, accessToken, resource, response.Ok)
|
||||
}
|
||||
|
||||
func ObjectAuth[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
|
||||
return ObjectsAuth(logger, []T{*item}, accessToken, resource)
|
||||
}
|
||||
|
||||
func ObjectAuthCreated[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
|
||||
return objectsAuth(logger, []T{*item}, accessToken, resource, response.Created)
|
||||
}
|
||||
35
api/edge/bff/interface/api/sresponse/orgnization.go
Normal file
35
api/edge/bff/interface/api/sresponse/orgnization.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type organizationsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Organizations []model.Organization `json:"organizations"`
|
||||
}
|
||||
|
||||
func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc {
|
||||
return Organizations(logger, []model.Organization{*organization}, accessToken)
|
||||
}
|
||||
|
||||
func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, organizationsResponse{
|
||||
Organizations: organizations,
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
type organizationPublicResponse struct {
|
||||
Organizations []model.OrganizationBase `json:"organizations"`
|
||||
}
|
||||
|
||||
func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc {
|
||||
return response.Ok(logger, organizationPublicResponse{
|
||||
[]model.OrganizationBase{*organization},
|
||||
})
|
||||
}
|
||||
389
api/edge/bff/interface/api/sresponse/payment.go
Normal file
389
api/edge/bff/interface/api/sresponse/payment.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type FeeLine struct {
|
||||
LedgerAccountRef string `json:"ledgerAccountRef,omitempty"`
|
||||
Amount *paymenttypes.Money `json:"amount,omitempty"`
|
||||
LineType string `json:"lineType,omitempty"`
|
||||
Side string `json:"side,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type FxQuote struct {
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
BaseCurrency string `json:"baseCurrency,omitempty"`
|
||||
QuoteCurrency string `json:"quoteCurrency,omitempty"`
|
||||
Side string `json:"side,omitempty"`
|
||||
Price string `json:"price,omitempty"`
|
||||
BaseAmount *paymenttypes.Money `json:"baseAmount,omitempty"`
|
||||
QuoteAmount *paymenttypes.Money `json:"quoteAmount,omitempty"`
|
||||
ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"`
|
||||
PricedAtUnixMs int64 `json:"pricedAtUnixMs,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
RateRef string `json:"rateRef,omitempty"`
|
||||
Firm bool `json:"firm,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentQuote struct {
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
IntentRef string `json:"intentRef,omitempty"`
|
||||
Amounts *QuoteAmounts `json:"amounts,omitempty"`
|
||||
Fees *QuoteFees `json:"fees,omitempty"`
|
||||
FxQuote *FxQuote `json:"fxQuote,omitempty"`
|
||||
}
|
||||
|
||||
type QuoteAmounts struct {
|
||||
SourcePrincipal *paymenttypes.Money `json:"sourcePrincipal,omitempty"`
|
||||
SourceDebitTotal *paymenttypes.Money `json:"sourceDebitTotal,omitempty"`
|
||||
DestinationSettlement *paymenttypes.Money `json:"destinationSettlement,omitempty"`
|
||||
}
|
||||
|
||||
type QuoteFees struct {
|
||||
Lines []FeeLine `json:"lines,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentQuotes struct {
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
Items []PaymentQuote `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
PaymentRef string `json:"paymentRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
FailureCode string `json:"failureCode,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
Operations []PaymentOperation `json:"operations,omitempty"`
|
||||
LastQuote *PaymentQuote `json:"lastQuote,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentOperation struct {
|
||||
StepRef string `json:"stepRef,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
FailureCode string `json:"failureCode,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type paymentQuoteResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
Quote *PaymentQuote `json:"quote"`
|
||||
}
|
||||
|
||||
type paymentQuotesResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Quote *PaymentQuotes `json:"quote"`
|
||||
}
|
||||
|
||||
type paymentsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Payments []Payment `json:"payments"`
|
||||
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
type paymentResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Payment *Payment `json:"payment"`
|
||||
}
|
||||
|
||||
// PaymentQuote wraps a payment quote with refreshed access token.
|
||||
func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *quotationv2.PaymentQuote, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentQuoteResponse{
|
||||
Quote: toPaymentQuote(quote),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
authResponse: authResponse{AccessToken: *token},
|
||||
})
|
||||
}
|
||||
|
||||
// PaymentQuotes wraps batch quotes with refreshed access token.
|
||||
func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv2.QuotePaymentsResponse, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentQuotesResponse{
|
||||
Quote: toPaymentQuotes(resp),
|
||||
authResponse: authResponse{AccessToken: *token},
|
||||
})
|
||||
}
|
||||
|
||||
// Payments wraps a list of payments with refreshed access token.
|
||||
func PaymentsResponse(logger mlogger.Logger, payments []*orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentsResponse{
|
||||
Payments: toPayments(payments),
|
||||
authResponse: authResponse{AccessToken: *token},
|
||||
})
|
||||
}
|
||||
|
||||
// PaymentsList wraps a list of payments with refreshed access token and pagination data.
|
||||
func PaymentsListResponse(logger mlogger.Logger, resp *orchestrationv2.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentsResponse{
|
||||
Payments: toPayments(resp.GetPayments()),
|
||||
Page: resp.GetPage(),
|
||||
authResponse: authResponse{AccessToken: *token},
|
||||
})
|
||||
}
|
||||
|
||||
// Payment wraps a payment with refreshed access token.
|
||||
func PaymentResponse(logger mlogger.Logger, payment *orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentResponse{
|
||||
Payment: toPayment(payment),
|
||||
authResponse: authResponse{AccessToken: *token},
|
||||
})
|
||||
}
|
||||
|
||||
func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]FeeLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, FeeLine{
|
||||
LedgerAccountRef: line.GetLedgerAccountRef(),
|
||||
Amount: toMoney(line.GetMoney()),
|
||||
LineType: enumJSONName(line.GetLineType().String()),
|
||||
Side: enumJSONName(line.GetSide().String()),
|
||||
Meta: line.GetMeta(),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toFxQuote(q *oraclev1.Quote) *FxQuote {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
pair := q.GetPair()
|
||||
pricedAtUnixMs := int64(0)
|
||||
if ts := q.GetPricedAt(); ts != nil {
|
||||
pricedAtUnixMs = ts.AsTime().UnixMilli()
|
||||
}
|
||||
base := ""
|
||||
quote := ""
|
||||
if pair != nil {
|
||||
base = pair.GetBase()
|
||||
quote = pair.GetQuote()
|
||||
}
|
||||
return &FxQuote{
|
||||
QuoteRef: q.GetQuoteRef(),
|
||||
BaseCurrency: base,
|
||||
QuoteCurrency: quote,
|
||||
Side: enumJSONName(q.GetSide().String()),
|
||||
Price: q.GetPrice().GetValue(),
|
||||
BaseAmount: toMoney(q.GetBaseAmount()),
|
||||
QuoteAmount: toMoney(q.GetQuoteAmount()),
|
||||
ExpiresAtUnixMs: q.GetExpiresAtUnixMs(),
|
||||
PricedAtUnixMs: pricedAtUnixMs,
|
||||
Provider: q.GetProvider(),
|
||||
RateRef: q.GetRateRef(),
|
||||
Firm: q.GetFirm(),
|
||||
}
|
||||
}
|
||||
|
||||
func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
amounts := toQuoteAmounts(q)
|
||||
fees := toQuoteFees(q.GetFeeLines())
|
||||
return &PaymentQuote{
|
||||
QuoteRef: q.GetQuoteRef(),
|
||||
IntentRef: strings.TrimSpace(q.GetIntentRef()),
|
||||
Amounts: amounts,
|
||||
Fees: fees,
|
||||
FxQuote: toFxQuote(q.GetFxQuote()),
|
||||
}
|
||||
}
|
||||
|
||||
func toPaymentQuotes(resp *quotationv2.QuotePaymentsResponse) *PaymentQuotes {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
items := make([]PaymentQuote, 0, len(resp.GetQuotes()))
|
||||
for _, quote := range resp.GetQuotes() {
|
||||
if dto := toPaymentQuote(quote); dto != nil {
|
||||
items = append(items, *dto)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = nil
|
||||
}
|
||||
return &PaymentQuotes{
|
||||
IdempotencyKey: resp.GetIdempotencyKey(),
|
||||
QuoteRef: resp.GetQuoteRef(),
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func toQuoteAmounts(q *quotationv2.PaymentQuote) *QuoteAmounts {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
amounts := &QuoteAmounts{
|
||||
SourcePrincipal: toMoney(q.GetTransferPrincipalAmount()),
|
||||
SourceDebitTotal: toMoney(q.GetPayerTotalDebitAmount()),
|
||||
DestinationSettlement: toMoney(q.GetDestinationAmount()),
|
||||
}
|
||||
if amounts.SourcePrincipal == nil && amounts.SourceDebitTotal == nil && amounts.DestinationSettlement == nil {
|
||||
return nil
|
||||
}
|
||||
return amounts
|
||||
}
|
||||
|
||||
func toQuoteFees(lines []*feesv1.DerivedPostingLine) *QuoteFees {
|
||||
feeLines := toFeeLines(lines)
|
||||
if len(feeLines) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &QuoteFees{Lines: feeLines}
|
||||
}
|
||||
|
||||
func toPayments(items []*orchestrationv2.Payment) []Payment {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]Payment, 0, len(items))
|
||||
for _, item := range items {
|
||||
if p := toPayment(item); p != nil {
|
||||
result = append(result, *p)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toPayment(p *orchestrationv2.Payment) *Payment {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
operations := toUserVisibleOperations(p.GetStepExecutions())
|
||||
failureCode, failureReason := firstFailure(operations)
|
||||
return &Payment{
|
||||
PaymentRef: p.GetPaymentRef(),
|
||||
State: enumJSONName(p.GetState().String()),
|
||||
FailureCode: failureCode,
|
||||
FailureReason: failureReason,
|
||||
Operations: operations,
|
||||
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
|
||||
CreatedAt: timestampAsTime(p.GetCreatedAt()),
|
||||
Meta: paymentMeta(p),
|
||||
IdempotencyKey: "",
|
||||
}
|
||||
}
|
||||
|
||||
func firstFailure(operations []PaymentOperation) (string, string) {
|
||||
for _, op := range operations {
|
||||
if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(op.FailureCode), strings.TrimSpace(op.FailureReason)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation {
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
ops := make([]PaymentOperation, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, toPaymentOperation(step))
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
|
||||
op := PaymentOperation{
|
||||
StepRef: step.GetStepRef(),
|
||||
Code: step.GetStepCode(),
|
||||
State: enumJSONName(step.GetState().String()),
|
||||
Label: strings.TrimSpace(step.GetUserLabel()),
|
||||
StartedAt: timestampAsTime(step.GetStartedAt()),
|
||||
CompletedAt: timestampAsTime(step.GetCompletedAt()),
|
||||
}
|
||||
failure := step.GetFailure()
|
||||
if failure == nil {
|
||||
return op
|
||||
}
|
||||
op.FailureCode = enumJSONName(failure.GetCategory().String())
|
||||
op.FailureReason = strings.TrimSpace(failure.GetMessage())
|
||||
if op.FailureReason == "" {
|
||||
op.FailureReason = strings.TrimSpace(failure.GetCode())
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
|
||||
switch visibility {
|
||||
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
|
||||
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
|
||||
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func paymentMeta(p *orchestrationv2.Payment) map[string]string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
meta := make(map[string]string)
|
||||
if quotationRef := strings.TrimSpace(p.GetQuotationRef()); quotationRef != "" {
|
||||
meta["quotationRef"] = quotationRef
|
||||
}
|
||||
if clientPaymentRef := strings.TrimSpace(p.GetClientPaymentRef()); clientPaymentRef != "" {
|
||||
meta["clientPaymentRef"] = clientPaymentRef
|
||||
}
|
||||
if version := p.GetVersion(); version > 0 {
|
||||
meta["version"] = strconv.FormatUint(version, 10)
|
||||
}
|
||||
if len(meta) == 0 {
|
||||
return nil
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func timestampAsTime(ts *timestamppb.Timestamp) time.Time {
|
||||
if ts == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return ts.AsTime()
|
||||
}
|
||||
|
||||
func enumJSONName(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
136
api/edge/bff/interface/api/sresponse/payment_test.go
Normal file
136
api/edge/bff/interface/api/sresponse/payment_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
|
||||
steps := []*orchestrationv2.StepExecution{
|
||||
{
|
||||
StepRef: "hidden",
|
||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
|
||||
},
|
||||
{
|
||||
StepRef: "user",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING,
|
||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
|
||||
},
|
||||
{
|
||||
StepRef: "unspecified",
|
||||
StepCode: "hop.4.card_payout.observe",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED,
|
||||
},
|
||||
{
|
||||
StepRef: "backoffice",
|
||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
|
||||
},
|
||||
}
|
||||
|
||||
ops := toUserVisibleOperations(steps)
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
|
||||
}
|
||||
if got, want := ops[0].StepRef, "user"; got != want {
|
||||
t.Fatalf("first operation step_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := ops[1].StepRef, "unspecified"; got != want {
|
||||
t.Fatalf("second operation step_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentFailureUsesVisibleOperationsOnly(t *testing.T) {
|
||||
dto := toPayment(&orchestrationv2.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
|
||||
StepExecutions: []*orchestrationv2.StepExecution{
|
||||
{
|
||||
StepRef: "hidden_failed",
|
||||
StepCode: "edge.1_2.ledger.debit",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
|
||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
|
||||
Failure: &orchestrationv2.Failure{
|
||||
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
|
||||
Message: "internal hold release failure",
|
||||
},
|
||||
},
|
||||
{
|
||||
StepRef: "user_failed",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
|
||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
|
||||
Failure: &orchestrationv2.Failure{
|
||||
Category: sharedv1.PaymentFailureCode_FAILURE_CHAIN,
|
||||
Message: "card declined",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if dto == nil {
|
||||
t.Fatal("expected non-nil payment dto")
|
||||
}
|
||||
if got, want := dto.FailureCode, "failure_chain"; got != want {
|
||||
t.Fatalf("failure_code mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := dto.FailureReason, "card declined"; got != want {
|
||||
t.Fatalf("failure_reason mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if len(dto.Operations) != 1 {
|
||||
t.Fatalf("operations count mismatch: got=%d want=1", len(dto.Operations))
|
||||
}
|
||||
if got, want := dto.Operations[0].StepRef, "user_failed"; got != want {
|
||||
t.Fatalf("visible operation mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentIgnoresHiddenFailures(t *testing.T) {
|
||||
dto := toPayment(&orchestrationv2.Payment{
|
||||
PaymentRef: "pay-2",
|
||||
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
|
||||
StepExecutions: []*orchestrationv2.StepExecution{
|
||||
{
|
||||
StepRef: "hidden_failed",
|
||||
StepCode: "edge.1_2.ledger.release",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
|
||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
|
||||
Failure: &orchestrationv2.Failure{
|
||||
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
|
||||
Message: "backoffice only failure",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if dto == nil {
|
||||
t.Fatal("expected non-nil payment dto")
|
||||
}
|
||||
if got := dto.FailureCode; got != "" {
|
||||
t.Fatalf("expected empty failure_code, got=%q", got)
|
||||
}
|
||||
if got := dto.FailureReason; got != "" {
|
||||
t.Fatalf("expected empty failure_reason, got=%q", got)
|
||||
}
|
||||
if len(dto.Operations) != 0 {
|
||||
t.Fatalf("expected no visible operations, got=%d", len(dto.Operations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
|
||||
dto := toPaymentQuote("ationv2.PaymentQuote{
|
||||
QuoteRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
})
|
||||
if dto == nil {
|
||||
t.Fatal("expected non-nil quote dto")
|
||||
}
|
||||
if got, want := dto.QuoteRef, "quote-1"; got != want {
|
||||
t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := dto.IntentRef, "intent-1"; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
45
api/edge/bff/interface/api/sresponse/permissions.go
Normal file
45
api/edge/bff/interface/api/sresponse/permissions.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type permissionsDescription struct {
|
||||
Roles []model.RoleDescription `json:"roles"`
|
||||
Policies []model.PolicyDescription `json:"policies"`
|
||||
}
|
||||
|
||||
type permissionsData struct {
|
||||
Roles []model.Role `json:"roles"`
|
||||
Policies []model.RolePolicy `json:"policies"`
|
||||
Permissions []model.Permission `json:"permissions"`
|
||||
}
|
||||
|
||||
type permissionsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Descriptions permissionsDescription `json:"descriptions"`
|
||||
Permissions permissionsData `json:"permissions"`
|
||||
}
|
||||
|
||||
func Permisssions(logger mlogger.Logger,
|
||||
rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription,
|
||||
roles []model.Role, policies []model.RolePolicy, permissions []model.Permission,
|
||||
accessToken *TokenData,
|
||||
) http.HandlerFunc {
|
||||
return response.Ok(logger, permissionsResponse{
|
||||
Descriptions: permissionsDescription{
|
||||
Roles: rolesDescs,
|
||||
Policies: policiesDescs,
|
||||
},
|
||||
Permissions: permissionsData{
|
||||
Roles: roles,
|
||||
Policies: policies,
|
||||
Permissions: permissions,
|
||||
},
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
14
api/edge/bff/interface/api/sresponse/response.go
Normal file
14
api/edge/bff/interface/api/sresponse/response.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
emodel "github.com/tech/sendico/server/interface/model"
|
||||
)
|
||||
|
||||
type (
|
||||
HandlerFunc = func(r *http.Request) http.HandlerFunc
|
||||
AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc
|
||||
PendingAccountHandlerFunc = func(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc
|
||||
)
|
||||
27
api/edge/bff/interface/api/sresponse/result.go
Normal file
27
api/edge/bff/interface/api/sresponse/result.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type resultAuth struct {
|
||||
authResponse `json:",inline"`
|
||||
response.Result `json:",inline"`
|
||||
}
|
||||
|
||||
func Success(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, &resultAuth{
|
||||
Result: response.Result{Result: true},
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
func Failed(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Accepted(logger, &resultAuth{
|
||||
Result: response.Result{Result: false},
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
16
api/edge/bff/interface/api/sresponse/signup.go
Normal file
16
api/edge/bff/interface/api/sresponse/signup.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func SignUp(logger mlogger.Logger, account *model.Account) http.HandlerFunc {
|
||||
return response.Ok(
|
||||
logger,
|
||||
&account.AccountBase,
|
||||
)
|
||||
}
|
||||
23
api/edge/bff/interface/api/sresponse/signupavailability.go
Normal file
23
api/edge/bff/interface/api/sresponse/signupavailability.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type SignupAvailability struct {
|
||||
Login string `json:"login"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
func SignUpAvailability(logger mlogger.Logger, login string, available bool) http.HandlerFunc {
|
||||
return response.Ok(
|
||||
logger,
|
||||
SignupAvailability{
|
||||
Login: login,
|
||||
Available: available,
|
||||
},
|
||||
)
|
||||
}
|
||||
8
api/edge/bff/interface/api/sresponse/token.go
Normal file
8
api/edge/bff/interface/api/sresponse/token.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package sresponse
|
||||
|
||||
import "time"
|
||||
|
||||
type TokenData struct {
|
||||
Token string `json:"token"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
}
|
||||
292
api/edge/bff/interface/api/sresponse/wallet.go
Normal file
292
api/edge/bff/interface/api/sresponse/wallet.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type walletAsset struct {
|
||||
Chain string `json:"chain"`
|
||||
TokenSymbol string `json:"tokenSymbol"`
|
||||
ContractAddress string `json:"contractAddress"`
|
||||
}
|
||||
|
||||
type wallet struct {
|
||||
WalletRef string `json:"walletRef"`
|
||||
OrganizationRef string `json:"organizationRef"`
|
||||
OwnerRef string `json:"ownerRef"`
|
||||
Asset walletAsset `json:"asset"`
|
||||
DepositAddress string `json:"depositAddress"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type walletsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Wallets []wallet `json:"wallets"`
|
||||
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
type walletBalance struct {
|
||||
Available *paymenttypes.Money `json:"available,omitempty"`
|
||||
PendingInbound *paymenttypes.Money `json:"pendingInbound,omitempty"`
|
||||
PendingOutbound *paymenttypes.Money `json:"pendingOutbound,omitempty"`
|
||||
CalculatedAt string `json:"calculatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type walletBalanceResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Balance walletBalance `json:"balance"`
|
||||
}
|
||||
|
||||
func Wallets(logger mlogger.Logger, resp *chainv1.ListManagedWalletsResponse, accessToken *TokenData) http.HandlerFunc {
|
||||
dto := walletsResponse{
|
||||
Page: resp.GetPage(),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
}
|
||||
dto.Wallets = make([]wallet, 0, len(resp.GetWallets()))
|
||||
for _, w := range resp.GetWallets() {
|
||||
dto.Wallets = append(dto.Wallets, toWallet(w))
|
||||
}
|
||||
return response.Ok(logger, dto)
|
||||
}
|
||||
|
||||
func WalletBalance(logger mlogger.Logger, bal *chainv1.WalletBalance, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, walletBalanceResponse{
|
||||
Balance: toWalletBalance(bal),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
func toWallet(w *chainv1.ManagedWallet) wallet {
|
||||
if w == nil {
|
||||
return wallet{}
|
||||
}
|
||||
asset := w.GetAsset()
|
||||
chain := ""
|
||||
token := ""
|
||||
contract := ""
|
||||
if asset != nil {
|
||||
chain = chainNetworkValue(asset.GetChain())
|
||||
token = asset.GetTokenSymbol()
|
||||
contract = asset.GetContractAddress()
|
||||
}
|
||||
name := ""
|
||||
if d := w.GetDescribable(); d != nil {
|
||||
name = strings.TrimSpace(d.GetName())
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(w.GetMetadata()["name"])
|
||||
}
|
||||
if name == "" {
|
||||
name = w.GetWalletRef()
|
||||
}
|
||||
var description *string
|
||||
if d := w.GetDescribable(); d != nil && d.Description != nil {
|
||||
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if description == nil {
|
||||
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
return wallet{
|
||||
WalletRef: w.GetWalletRef(),
|
||||
OrganizationRef: w.GetOrganizationRef(),
|
||||
OwnerRef: w.GetOwnerRef(),
|
||||
Asset: walletAsset{
|
||||
Chain: chain,
|
||||
TokenSymbol: token,
|
||||
ContractAddress: contract,
|
||||
},
|
||||
DepositAddress: w.GetDepositAddress(),
|
||||
Status: w.GetStatus().String(),
|
||||
Metadata: w.GetMetadata(),
|
||||
Name: name,
|
||||
Description: description,
|
||||
CreatedAt: tsToString(w.GetCreatedAt()),
|
||||
UpdatedAt: tsToString(w.GetUpdatedAt()),
|
||||
}
|
||||
}
|
||||
|
||||
func toWalletBalance(b *chainv1.WalletBalance) walletBalance {
|
||||
if b == nil {
|
||||
return walletBalance{}
|
||||
}
|
||||
return walletBalance{
|
||||
Available: toMoney(b.GetAvailable()),
|
||||
PendingInbound: toMoney(b.GetPendingInbound()),
|
||||
PendingOutbound: toMoney(b.GetPendingOutbound()),
|
||||
CalculatedAt: tsToString(b.GetCalculatedAt()),
|
||||
}
|
||||
}
|
||||
|
||||
func tsToString(ts *timestamppb.Timestamp) string {
|
||||
if ts == nil {
|
||||
return ""
|
||||
}
|
||||
return ts.AsTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func chainNetworkValue(chain chainv1.ChainNetwork) string {
|
||||
name := chain.String()
|
||||
if !strings.HasPrefix(name, "CHAIN_NETWORK_") {
|
||||
return "unspecified"
|
||||
}
|
||||
trimmed := strings.TrimPrefix(name, "CHAIN_NETWORK_")
|
||||
if trimmed == "" {
|
||||
return "unspecified"
|
||||
}
|
||||
return strings.ToLower(trimmed)
|
||||
}
|
||||
|
||||
// WalletsFromAccounts converts connector accounts to wallet response format.
|
||||
// Used when querying multiple gateways via discovery.
|
||||
func WalletsFromAccounts(logger mlogger.Logger, accounts []*connectorv1.Account, accessToken *TokenData) http.HandlerFunc {
|
||||
dto := walletsResponse{
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
}
|
||||
dto.Wallets = make([]wallet, 0, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
if acc == nil {
|
||||
continue
|
||||
}
|
||||
dto.Wallets = append(dto.Wallets, accountToWallet(acc))
|
||||
}
|
||||
return response.Ok(logger, dto)
|
||||
}
|
||||
|
||||
func accountToWallet(acc *connectorv1.Account) wallet {
|
||||
if acc == nil {
|
||||
return wallet{}
|
||||
}
|
||||
|
||||
// Extract wallet details from provider details
|
||||
details := map[string]interface{}{}
|
||||
if acc.GetProviderDetails() != nil {
|
||||
details = acc.GetProviderDetails().AsMap()
|
||||
}
|
||||
|
||||
walletRef := ""
|
||||
if ref := acc.GetRef(); ref != nil {
|
||||
walletRef = strings.TrimSpace(ref.GetAccountId())
|
||||
}
|
||||
if v := stringFromDetails(details, "wallet_ref"); v != "" {
|
||||
walletRef = v
|
||||
}
|
||||
|
||||
organizationRef := stringFromDetails(details, "organization_ref")
|
||||
ownerRef := strings.TrimSpace(acc.GetOwnerRef())
|
||||
if v := stringFromDetails(details, "owner_ref"); v != "" {
|
||||
ownerRef = v
|
||||
}
|
||||
|
||||
chain := stringFromDetails(details, "network")
|
||||
tokenSymbol := stringFromDetails(details, "token_symbol")
|
||||
contractAddress := stringFromDetails(details, "contract_address")
|
||||
depositAddress := stringFromDetails(details, "deposit_address")
|
||||
|
||||
name := ""
|
||||
if d := acc.GetDescribable(); d != nil {
|
||||
name = strings.TrimSpace(d.GetName())
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(acc.GetLabel())
|
||||
}
|
||||
if name == "" {
|
||||
name = walletRef
|
||||
}
|
||||
|
||||
var description *string
|
||||
if d := acc.GetDescribable(); d != nil && d.Description != nil {
|
||||
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
status := acc.GetState().String()
|
||||
// Convert connector state to wallet status format
|
||||
switch acc.GetState() {
|
||||
case connectorv1.AccountState_ACCOUNT_ACTIVE:
|
||||
status = "MANAGED_WALLET_ACTIVE"
|
||||
case connectorv1.AccountState_ACCOUNT_SUSPENDED:
|
||||
status = "MANAGED_WALLET_SUSPENDED"
|
||||
case connectorv1.AccountState_ACCOUNT_CLOSED:
|
||||
status = "MANAGED_WALLET_CLOSED"
|
||||
}
|
||||
|
||||
return wallet{
|
||||
WalletRef: walletRef,
|
||||
OrganizationRef: organizationRef,
|
||||
OwnerRef: ownerRef,
|
||||
Asset: walletAsset{
|
||||
Chain: chain,
|
||||
TokenSymbol: tokenSymbol,
|
||||
ContractAddress: contractAddress,
|
||||
},
|
||||
DepositAddress: depositAddress,
|
||||
Status: status,
|
||||
Name: name,
|
||||
Description: description,
|
||||
CreatedAt: tsToString(acc.GetCreatedAt()),
|
||||
UpdatedAt: tsToString(acc.GetUpdatedAt()),
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromDetails(details map[string]interface{}, key string) string {
|
||||
if details == nil {
|
||||
return ""
|
||||
}
|
||||
if value, ok := details[key]; ok {
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WalletBalanceFromConnector converts connector balance to wallet balance response format.
|
||||
// Used when querying gateways via discovery.
|
||||
func WalletBalanceFromConnector(logger mlogger.Logger, bal *connectorv1.Balance, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, walletBalanceResponse{
|
||||
Balance: connectorBalanceToWalletBalance(bal),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
func connectorBalanceToWalletBalance(b *connectorv1.Balance) walletBalance {
|
||||
if b == nil {
|
||||
return walletBalance{}
|
||||
}
|
||||
return walletBalance{
|
||||
Available: connectorMoneyToModel(b.GetAvailable()),
|
||||
PendingInbound: connectorMoneyToModel(b.GetPendingInbound()),
|
||||
PendingOutbound: connectorMoneyToModel(b.GetPendingOutbound()),
|
||||
CalculatedAt: tsToString(b.GetCalculatedAt()),
|
||||
}
|
||||
}
|
||||
|
||||
func connectorMoneyToModel(m *moneyv1.Money) *paymenttypes.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: m.GetAmount(),
|
||||
Currency: m.GetCurrency(),
|
||||
}
|
||||
}
|
||||
57
api/edge/bff/interface/api/sresponse/ws/response.go
Normal file
57
api/edge/bff/interface/api/sresponse/ws/response.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
r "github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/server/interface/api/ws"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
func respond(logger mlogger.Logger, conn *websocket.Conn, messageType, apiStatus, requestID string, data any) {
|
||||
message := ws.Message{
|
||||
BaseResponse: r.BaseResponse{
|
||||
Status: apiStatus,
|
||||
Data: data,
|
||||
},
|
||||
ID: requestID,
|
||||
MessageType: messageType,
|
||||
}
|
||||
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
logger.Warn("Failed to send error message", zap.Error(err), zap.Any("message", message))
|
||||
}
|
||||
}
|
||||
|
||||
func errorf(logger mlogger.Logger, messageType, requestID string, conn *websocket.Conn, resp r.ErrorResponse) {
|
||||
logger.Debug(
|
||||
"Writing error sresponse",
|
||||
zap.String("error", resp.Error),
|
||||
zap.String("details", resp.Details),
|
||||
zap.Int("code", resp.Code),
|
||||
)
|
||||
respond(logger, conn, messageType, api.MSError, requestID, &resp)
|
||||
}
|
||||
|
||||
func Ok(logger mlogger.Logger, requestID string, data any) ws.ResponseHandler {
|
||||
res := func(messageType string, conn *websocket.Conn) {
|
||||
logger.Debug("Successfully executed request", zap.Any("sresponse", data))
|
||||
respond(logger, conn, messageType, api.MSSuccess, requestID, data)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func Internal(logger mlogger.Logger, requestID string, err error) ws.ResponseHandler {
|
||||
res := func(messageType string, conn *websocket.Conn) {
|
||||
errorf(logger, messageType, requestID, conn,
|
||||
r.ErrorResponse{
|
||||
Error: "internal_error",
|
||||
Details: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
9
api/edge/bff/interface/api/ws/config.go
Normal file
9
api/edge/bff/interface/api/ws/config.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
ac "github.com/tech/sendico/server/internal/api/config"
|
||||
)
|
||||
|
||||
type (
|
||||
Config = ac.WebSocketConfig
|
||||
)
|
||||
12
api/edge/bff/interface/api/ws/handler.go
Normal file
12
api/edge/bff/interface/api/ws/handler.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type (
|
||||
ResponseHandler func(messageType string, conn *websocket.Conn)
|
||||
HandlerFunc func(ctx context.Context, msg Message) ResponseHandler
|
||||
)
|
||||
9
api/edge/bff/interface/api/ws/message.go
Normal file
9
api/edge/bff/interface/api/ws/message.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package ws
|
||||
|
||||
import "github.com/tech/sendico/pkg/api/http/response"
|
||||
|
||||
type Message struct {
|
||||
response.BaseResponse
|
||||
ID string `json:"id"`
|
||||
MessageType string `json:"messageType"`
|
||||
}
|
||||
31
api/edge/bff/interface/middleware/middleware.go
Normal file
31
api/edge/bff/interface/middleware/middleware.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
ai "github.com/tech/sendico/server/internal/api/config"
|
||||
)
|
||||
|
||||
type (
|
||||
TokenConfig = ai.TokenConfig
|
||||
Config = ai.Config
|
||||
Signature = ai.SignatureConf
|
||||
PasswordConfig = ai.PasswordConfig
|
||||
)
|
||||
|
||||
type MapClaims = ai.MapClaims
|
||||
|
||||
func getKey(osEnv string) any {
|
||||
if len(osEnv) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []byte(os.Getenv(osEnv))
|
||||
}
|
||||
|
||||
func SignatureConf(conf *Config) Signature {
|
||||
return Signature{
|
||||
PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)),
|
||||
PublicKey: getKey(conf.Signature.PublicKeyEnv),
|
||||
Algorithm: conf.Signature.Algorithm,
|
||||
}
|
||||
}
|
||||
117
api/edge/bff/interface/model/token.go
Normal file
117
api/edge/bff/interface/model/token.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
mduration "github.com/tech/sendico/pkg/mutil/duration"
|
||||
"github.com/tech/sendico/server/interface/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type AccountToken struct {
|
||||
AccountRef bson.ObjectID
|
||||
Login string
|
||||
Name string
|
||||
Locale string
|
||||
Expiration time.Time
|
||||
Pending bool
|
||||
}
|
||||
|
||||
func createAccountToken(a *model.Account, expiration int) AccountToken {
|
||||
return AccountToken{
|
||||
AccountRef: *a.GetID(),
|
||||
Login: a.Login,
|
||||
Name: a.Name,
|
||||
Locale: a.Locale,
|
||||
Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)),
|
||||
Pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
func getTokenParam(claims middleware.MapClaims, param string) (string, error) {
|
||||
id, ok := claims[param].(string)
|
||||
if !ok {
|
||||
return "", merrors.NoData(fmt.Sprintf("param '%s' not found", param))
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
const (
|
||||
paramNameID = "id"
|
||||
paramNameName = "name"
|
||||
paramNameLocale = "locale"
|
||||
paramNameLogin = "login"
|
||||
paramNameExpiration = "exp"
|
||||
paramNamePending = "pending"
|
||||
)
|
||||
|
||||
func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
|
||||
var at AccountToken
|
||||
var err error
|
||||
var account string
|
||||
if account, err = getTokenParam(claims, paramNameID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.AccountRef, err = bson.ObjectIDFromHex(account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.Login, err = getTokenParam(claims, paramNameLogin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.Name, err = getTokenParam(claims, paramNameName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pending, ok := claims[paramNamePending]; ok {
|
||||
if pbool, ok := pending.(bool); ok {
|
||||
at.Pending = pbool
|
||||
}
|
||||
}
|
||||
if expValue, ok := claims[paramNameExpiration]; ok {
|
||||
switch exp := expValue.(type) {
|
||||
case time.Time:
|
||||
at.Expiration = exp
|
||||
case float64:
|
||||
at.Expiration = time.Unix(int64(exp), 0)
|
||||
case int64:
|
||||
at.Expiration = time.Unix(exp, 0)
|
||||
default:
|
||||
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
|
||||
}
|
||||
} else {
|
||||
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
|
||||
}
|
||||
return &at, nil
|
||||
}
|
||||
|
||||
func Account2Claims(a *model.Account, expiration int) middleware.MapClaims {
|
||||
t := createAccountToken(a, expiration)
|
||||
return middleware.MapClaims{
|
||||
paramNameID: t.AccountRef.Hex(),
|
||||
paramNameLogin: t.Login,
|
||||
paramNameName: t.Name,
|
||||
paramNameLocale: t.Locale,
|
||||
paramNameExpiration: int64(t.Expiration.Unix()),
|
||||
paramNamePending: t.Pending,
|
||||
}
|
||||
}
|
||||
|
||||
func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims {
|
||||
t := createAccountToken(a, expirationMinutes/60)
|
||||
t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute)
|
||||
t.Pending = true
|
||||
return middleware.MapClaims{
|
||||
paramNameID: t.AccountRef.Hex(),
|
||||
paramNameLogin: t.Login,
|
||||
paramNameName: t.Name,
|
||||
paramNameLocale: t.Locale,
|
||||
paramNameExpiration: t.Expiration.Unix(),
|
||||
paramNamePending: t.Pending,
|
||||
}
|
||||
}
|
||||
11
api/edge/bff/interface/services/account/account.go
Normal file
11
api/edge/bff/interface/services/account/account.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/accountapiimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return accountapiimp.CreateAPI(a)
|
||||
}
|
||||
12
api/edge/bff/interface/services/fileservice/config/config.go
Normal file
12
api/edge/bff/interface/services/fileservice/config/config.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package fileservice
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
type StorageType string
|
||||
|
||||
const (
|
||||
LocalFS StorageType = "local_fs"
|
||||
AwsS3 StorageType = "aws_s3"
|
||||
)
|
||||
|
||||
type Config = model.DriverConfig[StorageType]
|
||||
11
api/edge/bff/interface/services/fileservice/fileservice.go
Normal file
11
api/edge/bff/interface/services/fileservice/fileservice.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package fileservice
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/fileserviceimp"
|
||||
)
|
||||
|
||||
func CreateAPI(a api.API, directory string) (mservice.MicroService, error) {
|
||||
return fileserviceimp.CreateAPI(a, directory)
|
||||
}
|
||||
11
api/edge/bff/interface/services/invitation/invitation.go
Normal file
11
api/edge/bff/interface/services/invitation/invitation.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package invitation
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/invitationimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return invitationimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/ledger/ledger.go
Normal file
11
api/edge/bff/interface/services/ledger/ledger.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/ledgerapiimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return ledgerapiimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/logo/logo.go
Normal file
11
api/edge/bff/interface/services/logo/logo.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package logo
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/logoimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return logoimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/organization/organization.go
Normal file
11
api/edge/bff/interface/services/organization/organization.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/organizationimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return organizationimp.CreateAPI(a)
|
||||
}
|
||||
12
api/edge/bff/interface/services/payment/payment.go
Normal file
12
api/edge/bff/interface/services/payment/payment.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/paymentapiimp"
|
||||
)
|
||||
|
||||
// Create wires payment orchestrator BFF API.
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return paymentapiimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/paymethod/paymethod.go
Normal file
11
api/edge/bff/interface/services/paymethod/paymethod.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package paymethod
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/paymethodsimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return paymethodsimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/permission/permission.go
Normal file
11
api/edge/bff/interface/services/permission/permission.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package permission
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/permissionsimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return permissionsimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/recipient/recipient.go
Normal file
11
api/edge/bff/interface/services/recipient/recipient.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package recipient
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/recipientimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return recipientimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/site/site.go
Normal file
11
api/edge/bff/interface/services/site/site.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/siteimp"
|
||||
)
|
||||
|
||||
func Create(a eapi.API) (mservice.MicroService, error) {
|
||||
return siteimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/verification/verification.go
Normal file
11
api/edge/bff/interface/services/verification/verification.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/verificationimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return verificationimp.CreateAPI(a)
|
||||
}
|
||||
11
api/edge/bff/interface/services/wallet/wallet.go
Normal file
11
api/edge/bff/interface/services/wallet/wallet.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/walletapiimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return walletapiimp.CreateAPI(a)
|
||||
}
|
||||
Reference in New Issue
Block a user