diff --git a/.gitignore b/.gitignore index e0ea5a5..b513afc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ node_modules/ # Docker image tarball image.tar + +# Binaries +bin/ diff --git a/Dockerfile b/Dockerfile index 6d05ad3..e4f72c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,11 @@ RUN go mod download COPY . . # Compile SCSS to CSS -RUN sass internal/web/assets/scss/main.scss internal/web/assets/css/output.css --style=compressed +# Install Sass +RUN npm install -g sass + +# Compile SCSS +RUN sass web/assets/scss/main.scss web/assets/css/output.css --style=compressed RUN CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go diff --git a/Makefile b/Makefile index 3cfb0f4..1a0350c 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ # Variables APP_NAME := billit -MAIN_PATH := ./cmd/api -SCSS_DIR := internal/web/assets/scss -CSS_DIR := internal/web/assets/css +MAIN_PATH := ./cmd/server +SCSS_DIR := web/assets/scss +CSS_DIR := web/assets/css # Default target all: scss build diff --git a/cmd/api/main.go b/cmd/server/main.go similarity index 92% rename from cmd/api/main.go rename to cmd/server/main.go index 11e753b..c60cd00 100644 --- a/cmd/api/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "time" "billit/internal/server" + "billit/web" ) func gracefulShutdown(apiServer *http.Server, done chan bool) { @@ -39,7 +40,7 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) { func main() { - server := server.NewServer() + server := server.NewServer(web.Files) // Create a done channel to signal when the shutdown is complete done := make(chan bool, 1) @@ -47,6 +48,7 @@ func main() { // Run graceful shutdown in a separate goroutine go gracefulShutdown(server, done) + log.Printf("Server is starting on %s", server.Addr) err := server.ListenAndServe() if err != nil && err != http.ErrServerClosed { panic(fmt.Sprintf("http server error: %s", err)) diff --git a/db/dev.db b/db/dev.db index 7570bd5..4436787 100644 Binary files a/db/dev.db and b/db/dev.db differ diff --git a/go.mod b/go.mod index 5f16b8b..4edaf30 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,11 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.13.4 - github.com/mattn/go-sqlite3 v1.14.32 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e ) +require github.com/mattn/go-sqlite3 v1.14.32 + require ( github.com/google/uuid v1.6.0 github.com/labstack/gommon v0.4.2 // indirect diff --git a/internal/api/handlers.go b/internal/api/handlers.go deleted file mode 100644 index 41d977a..0000000 --- a/internal/api/handlers.go +++ /dev/null @@ -1,28 +0,0 @@ -package api - -import ( - "billit/internal/database" - "net/http" - - "github.com/labstack/echo/v4" -) - -// Handlers holds dependencies for API handlers -type Handlers struct { - db database.Service -} - -// NewHandlers creates API handlers with db access -func NewHandlers(db database.Service) *Handlers { - return &Handlers{db: db} -} - -// HealthHandler returns the health status -func (h *Handlers) HealthHandler(c echo.Context) error { - return c.JSON(http.StatusOK, h.db.Health()) -} - -// Note: Product and Invoice API endpoints are disabled. -// All operations go through the authenticated web UI. -// To re-enable API access, add API authentication and update these handlers -// to accept userID from authenticated API requests. diff --git a/internal/auth/store.go b/internal/auth/store.go deleted file mode 100644 index 2ab928d..0000000 --- a/internal/auth/store.go +++ /dev/null @@ -1,69 +0,0 @@ -package auth - -import ( - "billit/internal/database" -) - -// DBUserStore adapts database.Service to auth.UserStore interface -type DBUserStore struct { - db database.Service -} - -// NewDBUserStore creates a new user store backed by the database -func NewDBUserStore(db database.Service) *DBUserStore { - return &DBUserStore{db: db} -} - -// CreateUser creates a new user -func (s *DBUserStore) CreateUser(email, passwordHash string) (*User, error) { - dbUser, err := s.db.CreateUser(email, passwordHash) - if err != nil { - return nil, err - } - return &User{ - ID: dbUser.ID, - Email: dbUser.Email, - Password: dbUser.Password, - CompanyDetails: dbUser.CompanyDetails, - BankDetails: dbUser.BankDetails, - CreatedAt: dbUser.CreatedAt, - }, nil -} - -// GetUserByEmail retrieves a user by email -func (s *DBUserStore) GetUserByEmail(email string) (*User, error) { - dbUser, err := s.db.GetUserByEmail(email) - if err != nil { - return nil, err - } - if dbUser == nil { - return nil, nil - } - return &User{ - ID: dbUser.ID, - Email: dbUser.Email, - Password: dbUser.Password, - CompanyDetails: dbUser.CompanyDetails, - BankDetails: dbUser.BankDetails, - CreatedAt: dbUser.CreatedAt, - }, nil -} - -// GetUserByID retrieves a user by ID -func (s *DBUserStore) GetUserByID(id string) (*User, error) { - dbUser, err := s.db.GetUserByID(id) - if err != nil { - return nil, err - } - if dbUser == nil { - return nil, nil - } - return &User{ - ID: dbUser.ID, - Email: dbUser.Email, - Password: dbUser.Password, - CompanyDetails: dbUser.CompanyDetails, - BankDetails: dbUser.BankDetails, - CreatedAt: dbUser.CreatedAt, - }, nil -} diff --git a/internal/database/buyer.go b/internal/database/buyer.go new file mode 100644 index 0000000..4a03778 --- /dev/null +++ b/internal/database/buyer.go @@ -0,0 +1,63 @@ +package database + +import ( + "billit/internal/models" + "database/sql" + "fmt" + "time" +) + +// CreateBuyerDetails creates a new buyer details entry +func (s *service) CreateBuyerDetails(userID string, name string, details string) (*models.BuyerDetails, error) { + id := fmt.Sprintf("%d", time.Now().UnixNano()) + _, err := s.db.Exec(`INSERT INTO buyer_details (id, user_id, name, details) VALUES (?, ?, ?, ?)`, id, userID, name, details) + if err != nil { + return nil, err + } + return s.GetBuyerDetails(id, userID) +} + +// UpdateBuyerDetails updates an existing buyer details entry +func (s *service) UpdateBuyerDetails(id string, userID string, name string, details string) error { + _, err := s.db.Exec(`UPDATE buyer_details SET name = ?, details = ? WHERE id = ? AND user_id = ?`, name, details, id, userID) + return err +} + +// GetBuyerDetails retrieves a buyer details entry by ID +func (s *service) GetBuyerDetails(id string, userID string) (*models.BuyerDetails, error) { + var b models.BuyerDetails + err := s.db.QueryRow(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID). + Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &b, nil +} + +// GetAllBuyerDetails retrieves all buyer details for a user +func (s *service) GetAllBuyerDetails(userID string) ([]models.BuyerDetails, error) { + rows, err := s.db.Query(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE user_id = ? ORDER BY name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var buyers []models.BuyerDetails + for rows.Next() { + var b models.BuyerDetails + if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt); err != nil { + return nil, err + } + buyers = append(buyers, b) + } + return buyers, nil +} + +// DeleteBuyerDetails removes a buyer details entry +func (s *service) DeleteBuyerDetails(id string, userID string) error { + _, err := s.db.Exec(`DELETE FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID) + return err +} diff --git a/internal/database/database.go b/internal/database/database.go deleted file mode 100644 index 79f567b..0000000 --- a/internal/database/database.go +++ /dev/null @@ -1,569 +0,0 @@ -package database - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "strconv" - "time" - - _ "github.com/joho/godotenv/autoload" - _ "github.com/mattn/go-sqlite3" -) - -// Product represents a product in the database -type Product struct { - SKU string `json:"sku"` - Name string `json:"name"` - HSNCode string `json:"hsn_code"` - BasePrice float64 `json:"base_price"` - WholesalePrice float64 `json:"wholesale_price"` - GSTRate float64 `json:"gst_rate"` - SmallOrderQty int `json:"small_order_qty"` - SmallOrderFee float64 `json:"small_order_fee"` // Convenience fee for orders below SmallOrderQty - Unit string `json:"unit"` // Unit of measurement (e.g., "pcs", "kg", "box") - UserID string `json:"user_id"` - CreatedAt string `json:"created_at"` -} - -// Invoice represents a stored invoice -type Invoice struct { - ID string `json:"id"` // UUID - HumanReadableID string `json:"human_readable_id"` // Formatted ID like INV/12-2025/001 - Data string `json:"data"` // JSON blob of invoice details - UserID string `json:"user_id"` - CreatedAt string `json:"created_at"` -} - -// User represents an authenticated user -type User struct { - ID string `json:"id"` - Email string `json:"email"` - Password string `json:"-"` - CompanyDetails string `json:"company_details"` // Multiline company details for invoice header - BankDetails string `json:"bank_details"` // Multiline bank details for invoice footer - InvoicePrefix string `json:"invoice_prefix"` // Prefix for invoice IDs (e.g., INV, BILL) - InvoiceCounter int `json:"invoice_counter"` // Auto-incrementing counter for invoice serial numbers - CreatedAt string `json:"created_at"` -} - -// BuyerDetails represents a buyer/customer for invoices -type BuyerDetails struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` // Display name for selection - Details string `json:"details"` // Multiline buyer details - CreatedAt string `json:"created_at"` -} - -// Service represents a service that interacts with a database. -// Service represents a service that interacts with a database. -type Service interface { - // Health returns a map of health status information. - Health() map[string]string - - // Close terminates the database connection. - Close() error - - // Product operations (user-scoped) - CreateProduct(p Product, userID string) error - UpdateProduct(p Product, userID string) error - GetAllProducts(userID string) ([]Product, error) - GetProductBySKU(sku string, userID string) (*Product, error) - DeleteProduct(sku string, userID string) error - - // Invoice operations (user-scoped) - CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error - GetInvoice(id string, userID string) (*Invoice, error) - GetAllInvoices(userID string) ([]Invoice, error) - GetRecentProducts(userID string, limit int) ([]Product, error) - GetRecentInvoices(userID string, limit int) ([]Invoice, error) - GetNextInvoiceNumber(userID string) (string, error) // Returns formatted invoice ID and increments counter - - // User operations - CreateUser(email, passwordHash string) (*User, error) - GetUserByEmail(email string) (*User, error) - GetUserByID(id string) (*User, error) - UpdateUserPassword(id string, passwordHash string) error - UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error - - // Buyer details operations - CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error) - UpdateBuyerDetails(id string, userID string, name string, details string) error - GetBuyerDetails(id string, userID string) (*BuyerDetails, error) - GetAllBuyerDetails(userID string) ([]BuyerDetails, error) - DeleteBuyerDetails(id string, userID string) error -} - -type service struct { - db *sql.DB -} - -var ( - dburl = os.Getenv("DB_PATH") - dbInstance *service -) - -func New() Service { - // Reuse Connection - if dbInstance != nil { - return dbInstance - } - - // Ensure the directory for the database file exists - if dburl != "" && dburl != ":memory:" { - dir := filepath.Dir(dburl) - if dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - log.Fatalf("Failed to create database directory: %v", err) - } - } - } - - db, err := sql.Open("sqlite3", dburl) - if err != nil { - log.Fatal(err) - } - - dbInstance = &service{ - db: db, - } - - // Initialize tables - dbInstance.initTables() - - return dbInstance -} - -func (s *service) initTables() { - // Products table with user ownership - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS products ( - sku TEXT NOT NULL, - name TEXT NOT NULL, - hsn_code TEXT, - base_price REAL NOT NULL, - wholesale_price REAL, - gst_rate REAL NOT NULL DEFAULT 0.18, - small_order_qty INTEGER DEFAULT 1, - small_order_fee REAL DEFAULT 0, - unit TEXT DEFAULT 'pcs', - user_id TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (sku, user_id) - ) - `) - if err != nil { - log.Printf("Error creating products table: %v", err) - } - - // Add user_id column if not exists (migration for existing DBs) - s.db.Exec(`ALTER TABLE products ADD COLUMN user_id TEXT DEFAULT ''`) - // Add unit column if not exists (migration for existing DBs) - s.db.Exec(`ALTER TABLE products ADD COLUMN unit TEXT DEFAULT 'pcs'`) - - // Invoices table with user ownership - _, err = s.db.Exec(` - CREATE TABLE IF NOT EXISTS invoices ( - id TEXT PRIMARY KEY, - human_readable_id TEXT DEFAULT '', - data TEXT NOT NULL, - user_id TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - log.Printf("Error creating invoices table: %v", err) - } - - // Add columns if not exists (migration for existing DBs) - s.db.Exec(`ALTER TABLE invoices ADD COLUMN user_id TEXT DEFAULT ''`) - s.db.Exec(`ALTER TABLE invoices ADD COLUMN human_readable_id TEXT DEFAULT ''`) - - // Create index on user_id for fast lookups - s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_products_user ON products(user_id)`) - s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)`) - - // Users table - _, err = s.db.Exec(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - company_details TEXT DEFAULT '', - bank_details TEXT DEFAULT '', - invoice_prefix TEXT DEFAULT 'INV', - invoice_counter INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - log.Printf("Error creating users table: %v", err) - } - - // Add columns if not exists (migration for existing DBs) - s.db.Exec(`ALTER TABLE users ADD COLUMN company_details TEXT DEFAULT ''`) - s.db.Exec(`ALTER TABLE users ADD COLUMN bank_details TEXT DEFAULT ''`) - s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`) - s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`) - s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`) - s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`) - - // Create index on email for fast lookups - _, err = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`) - if err != nil { - log.Printf("Error creating users email index: %v", err) - } - - // Buyer details table - _, err = s.db.Exec(` - CREATE TABLE IF NOT EXISTS buyer_details ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - details TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - log.Printf("Error creating buyer_details table: %v", err) - } - - // Create index on user_id for fast lookups - s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_buyer_details_user ON buyer_details(user_id)`) -} - -// CreateProduct inserts a new product for a user -func (s *service) CreateProduct(p Product, userID string) error { - _, err := s.db.Exec(` - INSERT INTO products (sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, p.SKU, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, userID) - return err -} - -// UpdateProduct updates an existing product for a user -func (s *service) UpdateProduct(p Product, userID string) error { - _, err := s.db.Exec(` - UPDATE products SET name=?, hsn_code=?, base_price=?, wholesale_price=?, gst_rate=?, small_order_qty=?, small_order_fee=?, unit=? - WHERE sku=? AND user_id=? - `, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, p.SKU, userID) - return err -} - -// GetAllProducts returns all products for a user -func (s *service) GetAllProducts(userID string) ([]Product, error) { - rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY name`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var products []Product - for rows.Next() { - var p Product - if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil { - return nil, err - } - products = append(products, p) - } - return products, nil -} - -// GetProductBySKU returns a single product by SKU for a user -func (s *service) GetProductBySKU(sku string, userID string) (*Product, error) { - var p Product - err := s.db.QueryRow(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE sku=? AND user_id=?`, sku, userID). - Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &p, nil -} - -// DeleteProduct removes a product by SKU for a user -func (s *service) DeleteProduct(sku string, userID string) error { - _, err := s.db.Exec(`DELETE FROM products WHERE sku=? AND user_id=?`, sku, userID) - return err -} - -// GetNextInvoiceNumber generates the next invoice ID in format PREFIX/MMM-YYYY/XXX and increments the counter -func (s *service) GetNextInvoiceNumber(userID string) (string, error) { - var prefix string - var counter int - - // Get current prefix and counter - err := s.db.QueryRow(`SELECT COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0) FROM users WHERE id = ?`, userID). - Scan(&prefix, &counter) - if err != nil { - return "", err - } - - // Increment counter - counter++ - - // Update counter in database - _, err = s.db.Exec(`UPDATE users SET invoice_counter = ? WHERE id = ?`, counter, userID) - if err != nil { - return "", err - } - - // Generate formatted invoice ID: PREFIX/MMM-YYYY/XXX - now := time.Now() - humanReadableID := fmt.Sprintf("%s/%s-%d/%03d", prefix, now.Month().String()[:3], now.Year(), counter) - - return humanReadableID, nil -} - -// CreateInvoice stores an invoice with UUID and human-readable ID for a user -func (s *service) CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error { - jsonData, err := json.Marshal(data) - if err != nil { - return err - } - _, err = s.db.Exec(`INSERT INTO invoices (id, human_readable_id, data, user_id) VALUES (?, ?, ?, ?)`, id, humanReadableID, string(jsonData), userID) - return err -} - -// GetInvoice retrieves an invoice by ID for a user -func (s *service) GetInvoice(id string, userID string) (*Invoice, error) { - var inv Invoice - err := s.db.QueryRow(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE id=? AND user_id=?`, id, userID). - Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &inv, nil -} - -// GetAllInvoices retrieves all invoices for a user -func (s *service) GetAllInvoices(userID string) ([]Invoice, error) { - rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var invoices []Invoice - for rows.Next() { - var inv Invoice - if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil { - return nil, err - } - invoices = append(invoices, inv) - } - return invoices, nil -} - -// GetRecentProducts returns the most recently added products for a user -func (s *service) GetRecentProducts(userID string, limit int) ([]Product, error) { - rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var products []Product - for rows.Next() { - var p Product - if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil { - return nil, err - } - products = append(products, p) - } - return products, nil -} - -// GetRecentInvoices returns the most recently generated invoices for a user -func (s *service) GetRecentInvoices(userID string, limit int) ([]Invoice, error) { - rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var invoices []Invoice - for rows.Next() { - var inv Invoice - if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil { - return nil, err - } - invoices = append(invoices, inv) - } - return invoices, nil -} - -// CreateUser creates a new user with hashed password -func (s *service) CreateUser(email, passwordHash string) (*User, error) { - id := fmt.Sprintf("%d", time.Now().UnixNano()) - _, err := s.db.Exec(`INSERT INTO users (id, email, password) VALUES (?, ?, ?)`, id, email, passwordHash) - if err != nil { - return nil, err - } - return s.GetUserByID(id) -} - -// GetUserByEmail retrieves a user by email -func (s *service) GetUserByEmail(email string) (*User, error) { - var u User - err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE email = ?`, email). - Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &u, nil -} - -// GetUserByID retrieves a user by ID -func (s *service) GetUserByID(id string) (*User, error) { - var u User - err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE id = ?`, id). - Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &u, nil -} - -// UpdateUserPassword updates a user's password hash -func (s *service) UpdateUserPassword(id string, passwordHash string) error { - _, err := s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, passwordHash, id) - return err -} - -// UpdateUserDetails updates a user's company and bank details -func (s *service) UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error { - _, err := s.db.Exec(`UPDATE users SET company_details = ?, bank_details = ?, invoice_prefix = ? WHERE id = ?`, companyDetails, bankDetails, invoicePrefix, id) - return err -} - -// CreateBuyerDetails creates a new buyer details entry -func (s *service) CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error) { - id := fmt.Sprintf("%d", time.Now().UnixNano()) - _, err := s.db.Exec(`INSERT INTO buyer_details (id, user_id, name, details) VALUES (?, ?, ?, ?)`, id, userID, name, details) - if err != nil { - return nil, err - } - return s.GetBuyerDetails(id, userID) -} - -// UpdateBuyerDetails updates an existing buyer details entry -func (s *service) UpdateBuyerDetails(id string, userID string, name string, details string) error { - _, err := s.db.Exec(`UPDATE buyer_details SET name = ?, details = ? WHERE id = ? AND user_id = ?`, name, details, id, userID) - return err -} - -// GetBuyerDetails retrieves a buyer details entry by ID -func (s *service) GetBuyerDetails(id string, userID string) (*BuyerDetails, error) { - var b BuyerDetails - err := s.db.QueryRow(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID). - Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &b, nil -} - -// GetAllBuyerDetails retrieves all buyer details for a user -func (s *service) GetAllBuyerDetails(userID string) ([]BuyerDetails, error) { - rows, err := s.db.Query(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE user_id = ? ORDER BY name`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var buyers []BuyerDetails - for rows.Next() { - var b BuyerDetails - if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt); err != nil { - return nil, err - } - buyers = append(buyers, b) - } - return buyers, nil -} - -// DeleteBuyerDetails removes a buyer details entry -func (s *service) DeleteBuyerDetails(id string, userID string) error { - _, err := s.db.Exec(`DELETE FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID) - return err -} - -// Health checks the health of the database connection by pinging the database. -// It returns a map with keys indicating various health statistics. -func (s *service) Health() map[string]string { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - stats := make(map[string]string) - - // Ping the database - err := s.db.PingContext(ctx) - if err != nil { - stats["status"] = "down" - stats["error"] = fmt.Sprintf("db down: %v", err) - log.Fatalf("db down: %v", err) // Log the error and terminate the program - return stats - } - - // Database is up, add more statistics - stats["status"] = "up" - stats["message"] = "It's healthy" - - // Get database stats (like open connections, in use, idle, etc.) - dbStats := s.db.Stats() - stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections) - stats["in_use"] = strconv.Itoa(dbStats.InUse) - stats["idle"] = strconv.Itoa(dbStats.Idle) - stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10) - stats["wait_duration"] = dbStats.WaitDuration.String() - stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10) - stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10) - - // Evaluate stats to provide a health message - if dbStats.OpenConnections > 40 { // Assuming 50 is the max for this example - stats["message"] = "The database is experiencing heavy load." - } - - if dbStats.WaitCount > 1000 { - stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks." - } - - if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 { - stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings." - } - - if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 { - stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern." - } - - return stats -} - -// Close closes the database connection. -// It logs a message indicating the disconnection from the specific database. -// If the connection is successfully closed, it returns nil. -// If an error occurs while closing the connection, it returns the error. -func (s *service) Close() error { - log.Printf("Disconnected from database: %s", dburl) - return s.db.Close() -} diff --git a/internal/database/invoice.go b/internal/database/invoice.go new file mode 100644 index 0000000..9885b5d --- /dev/null +++ b/internal/database/invoice.go @@ -0,0 +1,99 @@ +package database + +import ( + "billit/internal/models" + "database/sql" + "encoding/json" + "fmt" + "time" +) + +// GetNextInvoiceNumber generates the next invoice ID in format PREFIX/MMM-YYYY/XXX and increments the counter +func (s *service) GetNextInvoiceNumber(userID string) (string, error) { + var prefix string + var counter int + + // Get current prefix and counter + err := s.db.QueryRow(`SELECT COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0) FROM users WHERE id = ?`, userID). + Scan(&prefix, &counter) + if err != nil { + return "", err + } + + // Increment counter + counter++ + + // Update counter in database + _, err = s.db.Exec(`UPDATE users SET invoice_counter = ? WHERE id = ?`, counter, userID) + if err != nil { + return "", err + } + + // Generate formatted invoice ID: PREFIX/MMM-YYYY/XXX + now := time.Now() + humanReadableID := fmt.Sprintf("%s/%s-%d/%03d", prefix, now.Month().String()[:3], now.Year(), counter) + + return humanReadableID, nil +} + +// CreateInvoice stores an invoice with UUID and human-readable ID for a user +func (s *service) CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + _, err = s.db.Exec(`INSERT INTO invoices (id, human_readable_id, data, user_id) VALUES (?, ?, ?, ?)`, id, humanReadableID, string(jsonData), userID) + return err +} + +// GetInvoice retrieves an invoice by ID for a user +func (s *service) GetInvoice(id string, userID string) (*models.Invoice, error) { + var inv models.Invoice + err := s.db.QueryRow(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE id=? AND user_id=?`, id, userID). + Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &inv, nil +} + +// GetAllInvoices retrieves all invoices for a user +func (s *service) GetAllInvoices(userID string) ([]models.Invoice, error) { + rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var invoices []models.Invoice + for rows.Next() { + var inv models.Invoice + if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil { + return nil, err + } + invoices = append(invoices, inv) + } + return invoices, nil +} + +// GetRecentInvoices returns the most recently generated invoices for a user +func (s *service) GetRecentInvoices(userID string, limit int) ([]models.Invoice, error) { + rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var invoices []models.Invoice + for rows.Next() { + var inv models.Invoice + if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil { + return nil, err + } + invoices = append(invoices, inv) + } + return invoices, nil +} diff --git a/internal/database/product.go b/internal/database/product.go new file mode 100644 index 0000000..a17d7fc --- /dev/null +++ b/internal/database/product.go @@ -0,0 +1,82 @@ +package database + +import ( + "billit/internal/models" + "database/sql" +) + +// CreateProduct inserts a new product for a user +func (s *service) CreateProduct(p models.Product, userID string) error { + _, err := s.db.Exec(` + INSERT INTO products (sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, p.SKU, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, userID) + return err +} + +// UpdateProduct updates an existing product for a user +func (s *service) UpdateProduct(p models.Product, userID string) error { + _, err := s.db.Exec(` + UPDATE products SET name=?, hsn_code=?, base_price=?, wholesale_price=?, gst_rate=?, small_order_qty=?, small_order_fee=?, unit=? + WHERE sku=? AND user_id=? + `, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, p.SKU, userID) + return err +} + +// GetAllProducts returns all products for a user +func (s *service) GetAllProducts(userID string) ([]models.Product, error) { + rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var products []models.Product + for rows.Next() { + var p models.Product + if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil { + return nil, err + } + products = append(products, p) + } + return products, nil +} + +// GetProductBySKU returns a single product by SKU for a user +func (s *service) GetProductBySKU(sku string, userID string) (*models.Product, error) { + var p models.Product + err := s.db.QueryRow(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE sku=? AND user_id=?`, sku, userID). + Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +// DeleteProduct removes a product by SKU for a user +func (s *service) DeleteProduct(sku string, userID string) error { + _, err := s.db.Exec(`DELETE FROM products WHERE sku=? AND user_id=?`, sku, userID) + return err +} + +// GetRecentProducts returns the most recently added products for a user +func (s *service) GetRecentProducts(userID string, limit int) ([]models.Product, error) { + rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var products []models.Product + for rows.Next() { + var p models.Product + if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil { + return nil, err + } + products = append(products, p) + } + return products, nil +} diff --git a/internal/database/service.go b/internal/database/service.go new file mode 100644 index 0000000..967a901 --- /dev/null +++ b/internal/database/service.go @@ -0,0 +1,141 @@ +package database + +import ( + "billit/internal/models" + "context" + "database/sql" + "fmt" + "log" + "os" + "strconv" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Service represents the database service interface +type Service interface { + // Health returns database health status + Health() map[string]string + + // Close closes the database connection + Close() error + + // Buyer methods + CreateBuyerDetails(userID string, name string, details string) (*models.BuyerDetails, error) + UpdateBuyerDetails(id string, userID string, name string, details string) error + GetBuyerDetails(id string, userID string) (*models.BuyerDetails, error) + GetAllBuyerDetails(userID string) ([]models.BuyerDetails, error) + DeleteBuyerDetails(id string, userID string) error + + // User methods + CreateUser(email, passwordHash string) (*models.User, error) + GetUserByEmail(email string) (*models.User, error) + GetUserByID(id string) (*models.User, error) + UpdateUserPassword(id string, passwordHash string) error + UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error + + // Invoice methods + GetNextInvoiceNumber(userID string) (string, error) + CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error + GetInvoice(id string, userID string) (*models.Invoice, error) + GetAllInvoices(userID string) ([]models.Invoice, error) + GetRecentInvoices(userID string, limit int) ([]models.Invoice, error) + + // Product methods + CreateProduct(p models.Product, userID string) error + UpdateProduct(p models.Product, userID string) error + GetAllProducts(userID string) ([]models.Product, error) + GetProductBySKU(sku string, userID string) (*models.Product, error) + DeleteProduct(sku string, userID string) error + GetRecentProducts(userID string, limit int) ([]models.Product, error) +} + +type service struct { + db *sql.DB +} + +var dbInstance *service + +// New creates a new database service +func New() Service { + // Reuse connection if already established + if dbInstance != nil { + return dbInstance + } + + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./db/dev.db" + } + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatal(err) + } + + // Check connection + if err := db.Ping(); err != nil { + log.Fatal("Could not ping database:", err) + } + + dbInstance = &service{ + db: db, + } + + return dbInstance +} + +// Health checks the health of the database connection +func (s *service) Health() map[string]string { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + stats := make(map[string]string) + + // Ping the database + err := s.db.PingContext(ctx) + if err != nil { + stats["status"] = "down" + stats["error"] = fmt.Sprintf("db down: %v", err) + log.Printf("db down: %v", err) + return stats + } + + // Database is up, add more statistics + stats["status"] = "up" + stats["message"] = "It's healthy" + + // Get database stats + dbStats := s.db.Stats() + stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections) + stats["in_use"] = strconv.Itoa(dbStats.InUse) + stats["idle"] = strconv.Itoa(dbStats.Idle) + stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10) + stats["wait_duration"] = dbStats.WaitDuration.String() + stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10) + stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10) + + if dbStats.OpenConnections > 40 { + stats["message"] = "The database is experiencing heavy load." + } + + if dbStats.WaitCount > 1000 { + stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks." + } + + if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 { + stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings." + } + + if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 { + stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising connection usage." + } + + return stats +} + +// Close closes the database connection +func (s *service) Close() error { + return s.db.Close() +} diff --git a/internal/database/user.go b/internal/database/user.go new file mode 100644 index 0000000..152b37b --- /dev/null +++ b/internal/database/user.go @@ -0,0 +1,58 @@ +package database + +import ( + "billit/internal/models" + "database/sql" + "fmt" + "time" +) + +// CreateUser creates a new user with hashed password +func (s *service) CreateUser(email, passwordHash string) (*models.User, error) { + id := fmt.Sprintf("%d", time.Now().UnixNano()) + _, err := s.db.Exec(`INSERT INTO users (id, email, password) VALUES (?, ?, ?)`, id, email, passwordHash) + if err != nil { + return nil, err + } + return s.GetUserByID(id) +} + +// GetUserByEmail retrieves a user by email +func (s *service) GetUserByEmail(email string) (*models.User, error) { + var u models.User + err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE email = ?`, email). + Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + +// GetUserByID retrieves a user by ID +func (s *service) GetUserByID(id string) (*models.User, error) { + var u models.User + err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE id = ?`, id). + Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + +// UpdateUserPassword updates a user's password hash +func (s *service) UpdateUserPassword(id string, passwordHash string) error { + _, err := s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, passwordHash, id) + return err +} + +// UpdateUserDetails updates a user's company and bank details +func (s *service) UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error { + _, err := s.db.Exec(`UPDATE users SET company_details = ?, bank_details = ?, invoice_prefix = ? WHERE id = ?`, companyDetails, bankDetails, invoicePrefix, id) + return err +} diff --git a/internal/gst/calculator_test.go b/internal/gst/calculator_test.go deleted file mode 100644 index 37018f3..0000000 --- a/internal/gst/calculator_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package gst - -import ( - "testing" -) - -func TestCalculateLineItem(t *testing.T) { - c := NewCalculator() - - product := Product{ - SKU: "TEST01", - BasePrice: 100, - WholesalePrice: 90, - GSTRate: Rate18, - } - - // Case 1: Retail, Intra-state - item := c.CalculateLineItem(product, 2, CustomerRetail, false) - if item.UnitPrice != 100 { - t.Errorf("Expected UnitPrice 100, got %f", item.UnitPrice) - } - if item.TaxableVal != 200 { - t.Errorf("Expected TaxableVal 200, got %f", item.TaxableVal) - } - if item.CGSTAmount != 18 { // 9% of 200 - t.Errorf("Expected CGST 18, got %f", item.CGSTAmount) - } - if item.IGSTAmount != 0 { - t.Errorf("Expected IGST 0, got %f", item.IGSTAmount) - } - if item.TotalAmount != 236 { // 200 + 18 + 18 - t.Errorf("Expected Total 236, got %f", item.TotalAmount) - } - - // Case 2: Wholesale, Inter-state - item = c.CalculateLineItem(product, 10, CustomerWholesale, true) - if item.UnitPrice != 90 { - t.Errorf("Expected UnitPrice 90, got %f", item.UnitPrice) - } - if item.TaxableVal != 900 { - t.Errorf("Expected TaxableVal 900, got %f", item.TaxableVal) - } - if item.CGSTAmount != 0 { - t.Errorf("Expected CGST 0, got %f", item.CGSTAmount) - } - if item.IGSTAmount != 162 { // 18% of 900 - t.Errorf("Expected IGST 162, got %f", item.IGSTAmount) - } - if item.TotalAmount != 1062 { // 900 + 162 - t.Errorf("Expected Total 1062, got %f", item.TotalAmount) - } -} - -func TestCalculateInvoice(t *testing.T) { - c := NewCalculator() - product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18} - - item1 := c.CalculateLineItem(product, 1, CustomerRetail, false) - item2 := c.CalculateLineItem(product, 1, CustomerRetail, false) - - // Test with convenience fee (intra-state) - invoice := c.CalculateInvoice([]LineItem{item1, item2}, 50, false) - - // Convenience fee is taxed at 18% - // SubTotal should include convenience fee: 100 + 100 + 50 = 250 - expectedSubTotal := 250.0 - if invoice.SubTotal != expectedSubTotal { - t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal) - } - - // Convenience fee tax: 50 * 0.18 = 9 - expectedFeeTax := 9.0 - if invoice.ConvenienceFeeTax != expectedFeeTax { - t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax) - } - - // Total CGST: 9 + 9 + 4.5 = 22.5 (from items + half of fee tax) - expectedCGST := 22.5 - if invoice.TotalCGST != expectedCGST { - t.Errorf("Expected TotalCGST %f, got %f", expectedCGST, invoice.TotalCGST) - } - - // GrandTotal: SubTotal + CGST + SGST = 250 + 22.5 + 22.5 = 295 - expectedTotal := 295.0 - if invoice.GrandTotal != expectedTotal { - t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal) - } -} - -func TestCalculateInvoiceInterState(t *testing.T) { - c := NewCalculator() - product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18} - - item := c.CalculateLineItem(product, 1, CustomerRetail, true) - - // Test with convenience fee (inter-state) - invoice := c.CalculateInvoice([]LineItem{item}, 50, true) - - // SubTotal: 100 + 50 = 150 - expectedSubTotal := 150.0 - if invoice.SubTotal != expectedSubTotal { - t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal) - } - - // Convenience fee tax: 50 * 0.18 = 9 - expectedFeeTax := 9.0 - if invoice.ConvenienceFeeTax != expectedFeeTax { - t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax) - } - - // Total IGST: 18 + 9 = 27 (from item + fee tax) - expectedIGST := 27.0 - if invoice.TotalIGST != expectedIGST { - t.Errorf("Expected TotalIGST %f, got %f", expectedIGST, invoice.TotalIGST) - } - - // GrandTotal: SubTotal + IGST = 150 + 27 = 177 - expectedTotal := 177.0 - if invoice.GrandTotal != expectedTotal { - t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal) - } -} diff --git a/internal/handler/account.go b/internal/handler/account.go new file mode 100644 index 0000000..f485756 --- /dev/null +++ b/internal/handler/account.go @@ -0,0 +1,108 @@ +package handler + +import ( + "billit/internal/database" + "billit/internal/logic" + "billit/internal/view" + "net/http" + + "github.com/labstack/echo/v4" +) + +// AccountHandlers holds references for account operations +type AccountHandlers struct { + db database.Service + auth *logic.AuthService +} + +// NewAccountHandlers creates handlers with db and auth access +func NewAccountHandlers(db database.Service, authService *logic.AuthService) *AccountHandlers { + return &AccountHandlers{db: db, auth: authService} +} + +// AccountPageHandler renders the /account page +func (h *AccountHandlers) AccountPageHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + return view.RenderServerError(c, "Failed to load account details.") + } + + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "")) +} + +// UpdateDetailsHandler handles POST /account/details +func (h *AccountHandlers) UpdateDetailsHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + return view.RenderServerError(c, "Failed to load account details.") + } + + companyDetails := c.FormValue("company_details") + bankDetails := c.FormValue("bank_details") + invoicePrefix := c.FormValue("invoice_prefix") + if invoicePrefix == "" { + invoicePrefix = "INV" // Default prefix + } + + err = h.db.UpdateUserDetails(userID, companyDetails, bankDetails, invoicePrefix) + if err != nil { + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update details")) + } + + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, companyDetails, bankDetails, invoicePrefix, "Details updated successfully", "")) +} + +// ChangePasswordHandler handles POST /account/password +func (h *AccountHandlers) ChangePasswordHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + return view.RenderServerError(c, "Failed to load account details.") + } + + currentPassword := c.FormValue("current_password") + newPassword := c.FormValue("new_password") + confirmPassword := c.FormValue("confirm_password") + + // Validate current password + if !logic.CheckPassword(currentPassword, user.Password) { + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Current password is incorrect")) + } + + // Validate new password + if len(newPassword) < 8 { + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New password must be at least 8 characters")) + } + + if newPassword != confirmPassword { + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New passwords do not match")) + } + + // Hash new password + hash, err := logic.HashPassword(newPassword) + if err != nil { + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password")) + } + + // Update password in database + err = h.db.UpdateUserPassword(userID, hash) + if err != nil { + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password")) + } + + return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "Password changed successfully", "")) +} diff --git a/internal/web/auth.go b/internal/handler/auth.go similarity index 60% rename from internal/web/auth.go rename to internal/handler/auth.go index 592a048..639e7b0 100644 --- a/internal/web/auth.go +++ b/internal/handler/auth.go @@ -1,24 +1,62 @@ -package web +package handler import ( - "billit/internal/auth" + "billit/internal/logic" + "billit/internal/view" "net/http" "net/url" + "os" "strings" + "time" "github.com/labstack/echo/v4" ) // AuthHandlers holds auth service reference type AuthHandlers struct { - auth *auth.Service + auth *logic.AuthService } // NewAuthHandlers creates handlers with auth service -func NewAuthHandlers(authService *auth.Service) *AuthHandlers { +func NewAuthHandlers(authService *logic.AuthService) *AuthHandlers { return &AuthHandlers{auth: authService} } +// createAuthCookie creates an HTTP-only secure cookie for the token +func createAuthCookie(token string) *http.Cookie { + domain := os.Getenv("COOKIE_DOMAIN") + secure := os.Getenv("COOKIE_SECURE") == "true" + + return &http.Cookie{ + Name: "auth_token", + Value: token, + Path: "/", + Domain: domain, + MaxAge: int(24 * time.Hour.Seconds()), // Match token duration + HttpOnly: true, // Prevents JavaScript access + Secure: secure, // Only send over HTTPS in production + SameSite: http.SameSiteStrictMode, + } +} + +// clearAuthCookie returns a cookie that clears the auth token +func clearAuthCookie() *http.Cookie { + domain := os.Getenv("COOKIE_DOMAIN") + secure := os.Getenv("COOKIE_SECURE") == "true" + + return &http.Cookie{ + Name: "auth_token", + Value: "", + Path: "/", + Domain: domain, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + } +} + + // LoginPageHandler renders the login page (home page) func (h *AuthHandlers) LoginPageHandler(c echo.Context) error { // Check if already logged in @@ -32,7 +70,7 @@ func (h *AuthHandlers) LoginPageHandler(c echo.Context) error { } // Capture redirect URL from query param redirectURL := c.QueryParam("redirect") - return Render(c, LoginPage("", "", redirectURL)) + return view.Render(c, view.LoginPage("", "", redirectURL)) } // LoginHandler handles login form submission @@ -42,16 +80,16 @@ func (h *AuthHandlers) LoginHandler(c echo.Context) error { redirectURL := c.FormValue("redirect") if email == "" || password == "" { - return Render(c, LoginPage("Email and password are required", email, redirectURL)) + return view.Render(c, view.LoginPage("Email and password are required", email, redirectURL)) } token, err := h.auth.Login(email, password) if err != nil { - return Render(c, LoginPage("Invalid email or password", email, redirectURL)) + return view.Render(c, view.LoginPage("Invalid email or password", email, redirectURL)) } // Set HTTP-only cookie - cookie := h.auth.CreateAuthCookie(token) + cookie := createAuthCookie(token) c.SetCookie(cookie) // Redirect to original URL or home page @@ -63,7 +101,7 @@ func (h *AuthHandlers) LoginHandler(c echo.Context) error { // RegisterPageHandler renders the registration page func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error { - return Render(c, RegisterPage("", "")) + return view.Render(c, view.RegisterPage("", "")) } // RegisterHandler handles registration form submission @@ -73,23 +111,23 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error { confirmPassword := c.FormValue("confirm_password") if email == "" || password == "" { - return Render(c, RegisterPage("Email and password are required", email)) + return view.Render(c, view.RegisterPage("Email and password are required", email)) } if password != confirmPassword { - return Render(c, RegisterPage("Passwords do not match", email)) + return view.Render(c, view.RegisterPage("Passwords do not match", email)) } if len(password) < 8 { - return Render(c, RegisterPage("Password must be at least 8 characters", email)) + return view.Render(c, view.RegisterPage("Password must be at least 8 characters", email)) } _, err := h.auth.Register(email, password) if err != nil { - if err == auth.ErrUserExists { - return Render(c, RegisterPage("An account with this email already exists", email)) + if err == logic.ErrUserExists { + return view.Render(c, view.RegisterPage("An account with this email already exists", email)) } - return Render(c, RegisterPage(err.Error(), email)) + return view.Render(c, view.RegisterPage(err.Error(), email)) } // Auto-login after registration @@ -98,7 +136,7 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error { return c.Redirect(http.StatusFound, "/") } - cookie := h.auth.CreateAuthCookie(token) + cookie := createAuthCookie(token) c.SetCookie(cookie) return c.Redirect(http.StatusFound, "/home") @@ -106,7 +144,7 @@ func (h *AuthHandlers) RegisterHandler(c echo.Context) error { // LogoutHandler clears the auth cookie and redirects to login func (h *AuthHandlers) LogoutHandler(c echo.Context) error { - cookie := h.auth.ClearAuthCookie() + cookie := clearAuthCookie() c.SetCookie(cookie) return c.Redirect(http.StatusFound, "/") } @@ -124,9 +162,9 @@ func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { claims, err := h.auth.ValidateToken(cookie.Value) if err != nil { // Invalid/expired token - show session expired dialog - c.SetCookie(h.auth.ClearAuthCookie()) + c.SetCookie(clearAuthCookie()) redirectPath := url.QueryEscape(c.Request().URL.RequestURI()) - return Render(c, SessionExpiredPage(redirectPath)) + return view.Render(c, view.SessionExpiredPage(redirectPath)) } // Store user info in context @@ -135,4 +173,4 @@ func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return next(c) } -} +} \ No newline at end of file diff --git a/internal/web/billing.go b/internal/handler/billing.go similarity index 93% rename from internal/web/billing.go rename to internal/handler/billing.go index 52c47e0..ed6213e 100644 --- a/internal/web/billing.go +++ b/internal/handler/billing.go @@ -1,8 +1,10 @@ -package web +package handler +import "billit/internal/models" +import "billit/internal/view" import ( "billit/internal/database" - "billit/internal/gst" + "billit/internal/logic" "encoding/base64" "encoding/json" "fmt" @@ -98,13 +100,13 @@ func (h *BillingHandlers) BillingPageHandler(c echo.Context) error { userID := getUserID(c) products, err := h.db.GetAllProducts(userID) if err != nil { - products = []database.Product{} + products = []models.Product{} } buyers, err := h.db.GetAllBuyerDetails(userID) if err != nil { - buyers = []database.BuyerDetails{} + buyers = []models.BuyerDetails{} } - return Render(c, BillingPage(products, buyers)) + return view.Render(c, view.BillingPage(products, buyers)) } // CalculateBillHandler calculates the bill (HTMX endpoint) @@ -114,14 +116,14 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error { regionType := c.FormValue("region_type") includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" - cType := gst.CustomerRetail + cType := logic.CustomerRetail if customerType == "wholesale" { - cType = gst.CustomerWholesale + cType = logic.CustomerWholesale } isInterState := regionType == "inter" - calculator := gst.NewCalculator() - var items []gst.LineItem + calculator := logic.NewCalculator() + var items []logic.LineItem var totalFee float64 // Support up to 50 product slots for dynamic adding @@ -144,14 +146,14 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error { continue } - // Convert to gst.Product - product := gst.Product{ + // Convert to logic.Product + product := logic.Product{ SKU: dbProduct.SKU, Name: dbProduct.Name, HSNCode: dbProduct.HSNCode, BasePrice: dbProduct.BasePrice, WholesalePrice: dbProduct.WholesalePrice, - GSTRate: gst.Rate(dbProduct.GSTRate), + GSTRate: logic.Rate(dbProduct.GSTRate), SmallOrderQty: dbProduct.SmallOrderQty, SmallOrderFee: dbProduct.SmallOrderFee, Unit: dbProduct.Unit, @@ -169,7 +171,7 @@ func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error { invoice := calculator.CalculateInvoice(items, totalFee, isInterState) invoice.CustomerType = cType - return Render(c, InvoiceSummary(invoice)) + return view.Render(c, view.InvoiceSummary(invoice)) } // GenerateBillHandler generates final invoice with UUID and persists to DB @@ -184,14 +186,14 @@ func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error { buyerID := c.FormValue("buyer_id") includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" - cType := gst.CustomerRetail + cType := logic.CustomerRetail if customerType == "wholesale" { - cType = gst.CustomerWholesale + cType = logic.CustomerWholesale } isInterState := regionType == "inter" - calculator := gst.NewCalculator() - var items []gst.LineItem + calculator := logic.NewCalculator() + var items []logic.LineItem var totalFee float64 for i := 0; i < 50; i++ { @@ -212,13 +214,13 @@ func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error { continue } - product := gst.Product{ + product := logic.Product{ SKU: dbProduct.SKU, Name: dbProduct.Name, HSNCode: dbProduct.HSNCode, BasePrice: dbProduct.BasePrice, WholesalePrice: dbProduct.WholesalePrice, - GSTRate: gst.Rate(dbProduct.GSTRate), + GSTRate: logic.Rate(dbProduct.GSTRate), SmallOrderQty: dbProduct.SmallOrderQty, SmallOrderFee: dbProduct.SmallOrderFee, Unit: dbProduct.Unit, @@ -286,11 +288,11 @@ func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error { inv, err := h.db.GetInvoice(invoiceID, userID) if err != nil || inv == nil { - return RenderNotFound(c, "Invoice not found or you don't have access to it.") + return view.RenderNotFound(c, "Invoice not found or you don't have access to it.") } // Parse the JSON data back into Invoice struct - var invoice gst.Invoice + var invoice logic.Invoice if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil { return c.String(http.StatusInternalServerError, "failed to parse invoice data") } @@ -345,7 +347,7 @@ func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error { fmt.Fprintf(w, `
- Please read these terms carefully before using this software. By proceeding, you agree to the conditions below: -
-- Consult a qualified legal or financial advisor before relying on any data generated by this tool. -
-