refactor: restructure in entirety
This commit is contained in:
166
internal/logic/auth.go
Normal file
166
internal/logic/auth.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"billit/internal/models"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrInvalidToken = errors.New("invalid or expired token")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
// AuthConfig holds auth configuration
|
||||
type AuthConfig struct {
|
||||
JWTSecret []byte
|
||||
TokenDuration time.Duration
|
||||
}
|
||||
|
||||
// Claims represents JWT claims
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// AuthService handles authentication logic
|
||||
type AuthService struct {
|
||||
config AuthConfig
|
||||
users UserStore
|
||||
}
|
||||
|
||||
// UserStore interface for user persistence (subset of database.Service)
|
||||
type UserStore interface {
|
||||
CreateUser(email, passwordHash string) (*models.User, error)
|
||||
GetUserByEmail(email string) (*models.User, error)
|
||||
GetUserByID(id string) (*models.User, error)
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(users UserStore) *AuthService {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
// Generate a random secret if not provided (not recommended for production)
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
secret = hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
return &AuthService{
|
||||
config: AuthConfig{
|
||||
JWTSecret: []byte(secret),
|
||||
TokenDuration: 24 * time.Hour,
|
||||
},
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt with high cost
|
||||
func HashPassword(password string) (string, error) {
|
||||
// Use cost of 12 for good security/performance balance
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies a password against a hash
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Register creates a new user account
|
||||
func (s *AuthService) Register(email, password string) (*models.User, error) {
|
||||
// Check if user exists
|
||||
existing, _ := s.users.GetUserByEmail(email)
|
||||
if existing != nil {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if len(password) < 8 {
|
||||
return nil, errors.New("password must be at least 8 characters")
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.users.CreateUser(email, hash)
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT token
|
||||
func (s *AuthService) Login(email, password string) (string, error) {
|
||||
user, err := s.users.GetUserByEmail(email)
|
||||
if err != nil || user == nil {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !CheckPassword(password, user.Password) {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return s.generateToken(user)
|
||||
}
|
||||
|
||||
// generateToken creates a new JWT token for a user
|
||||
func (s *AuthService) generateToken(user *models.User) (string, error) {
|
||||
now := time.Now()
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(s.config.TokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
Issuer: "billit",
|
||||
Subject: user.ID,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.config.JWTSecret)
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims
|
||||
func (s *AuthService) ValidateToken(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, ErrInvalidToken
|
||||
}
|
||||
return s.config.JWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// GetUserFromToken retrieves the user from a valid token
|
||||
func (s *AuthService) GetUserFromToken(tokenString string) (*models.User, error) {
|
||||
claims, err := s.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.users.GetUserByID(claims.UserID)
|
||||
}
|
||||
158
internal/logic/calculator.go
Normal file
158
internal/logic/calculator.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
// Rate represents standard GST rates
|
||||
type Rate float64
|
||||
|
||||
const (
|
||||
Rate0 Rate = 0.0
|
||||
Rate5 Rate = 0.05
|
||||
Rate12 Rate = 0.12
|
||||
Rate18 Rate = 0.18
|
||||
Rate28 Rate = 0.28
|
||||
)
|
||||
|
||||
// CustomerType distinguishes between B2B (Wholesale) and B2C (Retail)
|
||||
type CustomerType string
|
||||
|
||||
const (
|
||||
CustomerWholesale CustomerType = "wholesale"
|
||||
CustomerRetail CustomerType = "retail"
|
||||
)
|
||||
|
||||
// Product represents a catalog item
|
||||
type Product struct {
|
||||
SKU string
|
||||
Name string
|
||||
HSNCode string
|
||||
BasePrice float64 // Price before tax
|
||||
WholesalePrice float64 // Discounted price for B2B
|
||||
GSTRate Rate
|
||||
SmallOrderQty int // Minimum quantity threshold
|
||||
SmallOrderFee float64 // Convenience fee when quantity is below threshold
|
||||
Unit string // Unit of measurement (e.g., "pcs", "kg", "box")
|
||||
}
|
||||
|
||||
// LineItem represents a single row in the invoice
|
||||
type LineItem struct {
|
||||
Product Product
|
||||
Quantity int
|
||||
UnitPrice float64 // Actual price applied (wholesale vs retail)
|
||||
TaxableVal float64 // Quantity * UnitPrice
|
||||
CGSTAmount float64
|
||||
SGSTAmount float64
|
||||
IGSTAmount float64
|
||||
TotalAmount float64
|
||||
}
|
||||
|
||||
// Invoice represents the full bill
|
||||
type Invoice struct {
|
||||
LineItems []LineItem
|
||||
SubTotal float64
|
||||
TotalCGST float64
|
||||
TotalSGST float64
|
||||
TotalIGST float64
|
||||
ConvenienceFee float64 // Flat fee for small orders (before tax)
|
||||
ConvenienceFeeTax float64 // GST on convenience fee (18% fixed)
|
||||
GrandTotal float64
|
||||
CustomerType CustomerType
|
||||
IsInterState bool // True if selling to a different state (IGST applies)
|
||||
CompanyDetails string // Multiline company details (displayed above invoice table)
|
||||
BuyerDetails string // Multiline buyer details (displayed above bank details)
|
||||
BuyerName string // Buyer's name
|
||||
BankDetails string // Multiline bank details (displayed at bottom of invoice)
|
||||
}
|
||||
|
||||
// Calculator handles the GST logic
|
||||
type Calculator struct{}
|
||||
|
||||
// NewCalculator creates a new calculator instance
|
||||
func NewCalculator() *Calculator {
|
||||
return &Calculator{}
|
||||
}
|
||||
|
||||
// CalculateLineItem computes taxes for a single line
|
||||
func (c *Calculator) CalculateLineItem(p Product, qty int, custType CustomerType, isInterState bool) LineItem {
|
||||
// Determine price based on customer type
|
||||
price := p.BasePrice
|
||||
if custType == CustomerWholesale {
|
||||
price = p.WholesalePrice
|
||||
}
|
||||
|
||||
taxableVal := price * float64(qty)
|
||||
rate := float64(p.GSTRate)
|
||||
|
||||
var cgst, sgst, igst float64
|
||||
|
||||
if isInterState {
|
||||
igst = taxableVal * rate
|
||||
} else {
|
||||
// Intra-state: Split tax between Center and State
|
||||
halfRate := rate / 2
|
||||
cgst = taxableVal * halfRate
|
||||
sgst = taxableVal * halfRate
|
||||
}
|
||||
|
||||
total := taxableVal + cgst + sgst + igst
|
||||
|
||||
return LineItem{
|
||||
Product: p,
|
||||
Quantity: qty,
|
||||
UnitPrice: price,
|
||||
TaxableVal: round(taxableVal),
|
||||
CGSTAmount: round(cgst),
|
||||
SGSTAmount: round(sgst),
|
||||
IGSTAmount: round(igst),
|
||||
TotalAmount: round(total),
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateInvoice computes totals for the entire invoice
|
||||
func (c *Calculator) CalculateInvoice(items []LineItem, fee float64, isInterState bool) Invoice {
|
||||
inv := Invoice{
|
||||
LineItems: items,
|
||||
ConvenienceFee: fee,
|
||||
IsInterState: isInterState,
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
inv.SubTotal += item.TaxableVal
|
||||
inv.TotalCGST += item.CGSTAmount
|
||||
inv.TotalSGST += item.SGSTAmount
|
||||
inv.TotalIGST += item.IGSTAmount
|
||||
}
|
||||
|
||||
// Convenience fee is taxable at 18% fixed rate
|
||||
if fee > 0 {
|
||||
feeTax := fee * 0.18 // 18% GST on convenience fee
|
||||
inv.ConvenienceFeeTax = round(feeTax)
|
||||
// Add convenience fee to taxable subtotal
|
||||
inv.SubTotal += fee
|
||||
// Add convenience fee tax to appropriate tax fields
|
||||
if isInterState {
|
||||
inv.TotalIGST += inv.ConvenienceFeeTax
|
||||
} else {
|
||||
// Split between CGST and SGST (9% each)
|
||||
inv.TotalCGST += round(feeTax / 2)
|
||||
inv.TotalSGST += round(feeTax / 2)
|
||||
}
|
||||
}
|
||||
|
||||
inv.GrandTotal = inv.SubTotal + inv.TotalCGST + inv.TotalSGST + inv.TotalIGST
|
||||
|
||||
// Rounding final totals
|
||||
inv.SubTotal = round(inv.SubTotal)
|
||||
inv.TotalCGST = round(inv.TotalCGST)
|
||||
inv.TotalSGST = round(inv.TotalSGST)
|
||||
inv.TotalIGST = round(inv.TotalIGST)
|
||||
inv.GrandTotal = round(inv.GrandTotal)
|
||||
|
||||
return inv
|
||||
}
|
||||
|
||||
func round(num float64) float64 {
|
||||
return math.Round(num*100) / 100
|
||||
}
|
||||
Reference in New Issue
Block a user