diff --git a/config/configs.go b/config/configs.go index b704bae..6a8dfe0 100644 --- a/config/configs.go +++ b/config/configs.go @@ -64,8 +64,10 @@ func LoadConfig() *Config { func (c *Config) Auth() *AuthConfig { return &AuthConfig{ - jwtTokenSecret: c.Jwt.Token.Secret, - jwtTokenExpiresTTL: c.Jwt.Token.ExpiresTTL, + jwtTokenSecret: c.Jwt.Token.Secret, + jwtTokenExpiresTTL: c.Jwt.Token.ExpiresTTL, + refreshTokenSecret: c.Jwt.RefreshToken.Secret, + refreshTokenExpiresTTL: c.Jwt.RefreshToken.ExpiresTTL, } } diff --git a/config/crypto.go b/config/crypto.go index e3e90c5..badbee5 100644 --- a/config/crypto.go +++ b/config/crypto.go @@ -3,8 +3,10 @@ package config import "time" type AuthConfig struct { - jwtTokenExpiresTTL int - jwtTokenSecret string + jwtTokenExpiresTTL int + jwtTokenSecret string + refreshTokenExpiresTTL int + refreshTokenSecret string } type JWT struct { @@ -20,3 +22,20 @@ func (c *AuthConfig) AccessTokenExpiresDate() time.Time { duration := time.Duration(c.jwtTokenExpiresTTL) return time.Now().UTC().Add(time.Minute * duration) } + +func (c *AuthConfig) RefreshTokenSecret() string { + return c.refreshTokenSecret +} + +func (c *AuthConfig) RefreshTokenExpiresDate() time.Time { + duration := time.Duration(c.refreshTokenExpiresTTL) + return time.Now().UTC().Add(time.Minute * duration) +} + +func (c *AuthConfig) AccessTokenTTL() time.Duration { + return time.Duration(c.jwtTokenExpiresTTL) * time.Minute +} + +func (c *AuthConfig) RefreshTokenTTL() time.Duration { + return time.Duration(c.refreshTokenExpiresTTL) * time.Minute +} diff --git a/config/jwt.go b/config/jwt.go index d8e9ff6..858c28e 100644 --- a/config/jwt.go +++ b/config/jwt.go @@ -1,8 +1,9 @@ package config type Jwt struct { - Token Token `mapstructure:"token"` - Customer Customer `mapstructure:"customer"` + Token Token `mapstructure:"token"` + RefreshToken RefreshToken `mapstructure:"refresh_token"` + Customer Customer `mapstructure:"customer"` } type Token struct { @@ -10,6 +11,11 @@ type Token struct { Secret string `mapstructure:"secret"` } +type RefreshToken struct { + ExpiresTTL int `mapstructure:"expires-ttl"` + Secret string `mapstructure:"secret"` +} + type Customer struct { ExpiresTTL int `mapstructure:"expires-ttl"` Secret string `mapstructure:"secret"` diff --git a/infra/development.yaml b/infra/development.yaml index 45172ed..6880e35 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -7,6 +7,9 @@ jwt: token: expires-ttl: 144000 secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs" + refresh_token: + expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes) + secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S" customer: expires-ttl: 7776000 secret: "z8d5TlFCT58Q$i0%S^2M&3WtE$PMgd" diff --git a/internal/app/app.go b/internal/app/app.go index 5f4084e..28e93e6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -365,8 +365,7 @@ type services struct { func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { authConfig := cfg.Auth() - jwtSecret := authConfig.AccessTokenSecret() - authService := service.NewAuthService(processors.userProcessor, jwtSecret) + authService := service.NewAuthService(processors.userProcessor, authConfig) organizationService := service.NewOrganizationService(processors.organizationProcessor) outletService := service.NewOutletService(processors.outletProcessor) outletSettingService := service.NewOutletSettingService(processors.outletSettingProcessor) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 026170a..81d2e9e 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -89,7 +89,7 @@ type ProductAnalyticsRequest struct { OutletID *uuid.UUID `form:"outlet_id,omitempty"` DateFrom string `form:"date_from" validate:"required"` DateTo string `form:"date_to" validate:"required"` - Limit int `form:"limit,default=10" validate:"min=1,max=100"` + Limit int `form:"limit,default=1000" validate:"min=1,max=1000"` } // ProductAnalyticsResponse represents the response for product analytics diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index 617f5e7..5607005 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -40,9 +40,11 @@ type LoginRequest struct { } type LoginResponse struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - User UserResponse `json:"user"` + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` + RefreshExpiresAt time.Time `json:"refresh_expires_at"` + User UserResponse `json:"user"` } type UserResponse struct { diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 5d343b5..d922a38 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -172,7 +172,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m // Set default limit if req.Limit <= 0 { - req.Limit = 10 + req.Limit = 1000 } // Get analytics data from repository diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index d2b0d79..e2f85a1 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -45,8 +45,10 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id"). Joins("JOIN orders o ON p.order_id = o.id"). Where("o.organization_id = ?", organizationID). + Where("o.is_void = ?", false). + Where("o.is_refund = ?", false). Where("p.status = ?", entities.PaymentTransactionStatusCompleted). - Where("p.created_at >= ? AND p.created_at <= ?", dateFrom, dateTo) + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) if outletID != nil { query = query.Where("o.outlet_id = ?", *outletID) @@ -81,7 +83,7 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz `+dateFormat+` as date, COALESCE(SUM(o.total_amount), 0) as sales, COUNT(o.id) as orders, - COALESCE(SUM(oi.quantity), 0) as items, + COALESCE(SUM(CASE WHEN oi.status != 'cancelled' AND oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as items, COALESCE(SUM(o.tax_amount), 0) as tax, COALESCE(SUM(o.discount_amount), 0) as discount, COALESCE(SUM(o.total_amount - o.tax_amount - o.discount_amount), 0) as net_sales @@ -89,6 +91,8 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz Joins("LEFT JOIN order_items oi ON o.id = oi.order_id"). Where("o.organization_id = ?", organizationID). Where("o.is_void = ?", false). + Where("o.is_refund = ?", false). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) if outletID != nil { @@ -114,10 +118,11 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ c.id as category_id, c.name as category_name, c.order as category_order, - COALESCE(SUM(oi.quantity), 0) as quantity_sold, - COALESCE(SUM(oi.total_price), 0) as revenue, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue, CASE - WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity) + WHEN SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) > 0 + THEN COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) / SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) ELSE 0 END as average_price, COUNT(DISTINCT oi.order_id) as order_count @@ -127,6 +132,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ Joins("JOIN orders o ON oi.order_id = o.id"). Where("o.organization_id = ?", organizationID). Where("o.is_void = ?", false). + Where("o.is_refund = ?", false). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("oi.status != ?", entities.OrderItemStatusCancelled). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) if outletID != nil { @@ -134,7 +142,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ } err := query. - Group("p.id, p.name, c.id, c.name, c.order"). + Group("p.id, p.name, c.id, c.name"). Order("revenue DESC"). Limit(limit). Scan(&results).Error @@ -150,8 +158,8 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con Select(` c.id as category_id, c.name as category_name, - COALESCE(SUM(oi.total_price), 0) as total_revenue, - COALESCE(SUM(oi.quantity), 0) as total_quantity, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count `). @@ -160,6 +168,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con Joins("JOIN orders o ON oi.order_id = o.id"). Where("o.organization_id = ?", organizationID). Where("o.is_void = ?", false). + Where("o.is_refund = ?", false). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("oi.status != ?", entities.OrderItemStatusCancelled). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) if outletID != nil { @@ -168,7 +179,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con err := query. Group("c.id, c.name"). - Order("total_revenue DESC"). + Order("c.name ASC"). Scan(&results).Error return results, err @@ -320,18 +331,18 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or p.name as product_name, c.id as category_id, c.name as category_name, - SUM(oi.quantity) as quantity_sold, - SUM(oi.total_price) as revenue, - SUM(oi.total_cost) as cost, - SUM(oi.total_price - oi.total_cost) as gross_profit, + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) as quantity_sold, + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) as revenue, + SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) as cost, + SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) as gross_profit, CASE - WHEN SUM(oi.total_price) > 0 - THEN (SUM(oi.total_price - oi.total_cost) / SUM(oi.total_price)) * 100 + WHEN SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) > 0 + THEN (SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) / SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)) * 100 ELSE 0 END as gross_profit_margin, - AVG(oi.unit_price) as average_price, - AVG(oi.unit_cost) as average_cost, - AVG(oi.unit_price - oi.unit_cost) as profit_per_unit + AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price ELSE NULL END) as average_price, + AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_cost ELSE NULL END) as average_cost, + AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price - oi.unit_cost ELSE NULL END) as profit_per_unit `). Joins("JOIN orders o ON oi.order_id = o.id"). Joins("JOIN products p ON oi.product_id = p.id"). @@ -340,10 +351,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or Where("o.status = ?", entities.OrderStatusCompleted). Where("o.payment_status = ?", entities.PaymentStatusCompleted). Where("o.is_void = false AND o.is_refund = false"). + Where("oi.status != ?", entities.OrderItemStatusCancelled). Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo). Group("p.id, p.name, c.id, c.name"). - Order("gross_profit DESC"). - Limit(20) + Order("p.name ASC"). + Limit(1000) if outletID != nil { productQuery = productQuery.Where("o.outlet_id = ?", *outletID) diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 9be78cd..6f0d85a 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -185,8 +185,8 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsRequest(req *models.Produ return fmt.Errorf("date_from cannot be after date_to") } - if req.Limit < 1 || req.Limit > 100 { - return fmt.Errorf("limit must be between 1 and 100") + if req.Limit < 1 || req.Limit > 1000 { + return fmt.Errorf("limit must be between 1 and 1000") } return nil diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index d00d51a..547e072 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "apskel-pos-be/config" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/models" "apskel-pos-be/internal/transformer" @@ -23,9 +24,11 @@ type AuthService interface { } type AuthServiceImpl struct { - userProcessor UserProcessor - jwtSecret string - tokenTTL time.Duration + userProcessor UserProcessor + jwtSecret string + refreshSecret string + tokenTTL time.Duration + refreshTokenTTL time.Duration } type Claims struct { @@ -36,11 +39,13 @@ type Claims struct { jwt.RegisteredClaims } -func NewAuthService(userProcessor UserProcessor, jwtSecret string) AuthService { +func NewAuthService(userProcessor UserProcessor, authConfig *config.AuthConfig) AuthService { return &AuthServiceImpl{ - userProcessor: userProcessor, - jwtSecret: jwtSecret, - tokenTTL: 24 * time.Hour, + userProcessor: userProcessor, + jwtSecret: authConfig.AccessTokenSecret(), + refreshSecret: authConfig.RefreshTokenSecret(), + tokenTTL: authConfig.AccessTokenTTL(), + refreshTokenTTL: authConfig.RefreshTokenTTL(), } } @@ -71,10 +76,17 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) return nil, fmt.Errorf("failed to generate token: %w", err) } + refreshToken, refreshExpiresAt, err := s.generateRefreshToken(userResponse) + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + return &contract.LoginResponse{ - Token: token, - ExpiresAt: expiresAt, - User: *contractUserResponse, + Token: token, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + RefreshExpiresAt: refreshExpiresAt, + User: *contractUserResponse, }, nil } @@ -98,9 +110,9 @@ func (s *AuthServiceImpl) ValidateToken(tokenString string) (*contract.UserRespo } func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) { - claims, err := s.parseToken(tokenString) + claims, err := s.parseRefreshToken(tokenString) if err != nil { - return nil, fmt.Errorf("invalid token: %w", err) + return nil, fmt.Errorf("invalid refresh token: %w", err) } userResponse, err := s.userProcessor.GetUserByID(ctx, claims.UserID) @@ -119,10 +131,17 @@ func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string) return nil, fmt.Errorf("failed to generate token: %w", err) } + refreshToken, refreshExpiresAt, err := s.generateRefreshToken(userResponse) + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + return &contract.LoginResponse{ - Token: newToken, - ExpiresAt: expiresAt, - User: *contractUserResponse, + Token: newToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + RefreshExpiresAt: refreshExpiresAt, + User: *contractUserResponse, }, nil } @@ -164,6 +183,32 @@ func (s *AuthServiceImpl) generateToken(user *models.UserResponse) (string, time return tokenString, expiresAt, nil } +func (s *AuthServiceImpl) generateRefreshToken(user *models.UserResponse) (string, time.Time, error) { + expiresAt := time.Now().Add(s.refreshTokenTTL) + + claims := &Claims{ + UserID: user.ID, + Email: user.Email, + Role: string(user.Role), + OrganizationID: user.OrganizationID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "apskel-pos-refresh", + Subject: user.ID.String(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(s.refreshSecret)) + if err != nil { + return "", time.Time{}, err + } + + return tokenString, expiresAt, nil +} + func (s *AuthServiceImpl) parseToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { @@ -182,3 +227,26 @@ func (s *AuthServiceImpl) parseToken(tokenString string) (*Claims, error) { return nil, errors.New("invalid token") } + +func (s *AuthServiceImpl) parseRefreshToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.refreshSecret), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + // Verify this is a refresh token by checking the issuer + if claims.Issuer != "apskel-pos-refresh" { + return nil, errors.New("not a valid refresh token") + } + return claims, nil + } + + return nil, errors.New("invalid refresh token") +}