From 17a2bce744a4d6074b1b956d9b4171ba2d684354 Mon Sep 17 00:00:00 2001 From: Arkaprabha Chakraborty Date: Sat, 6 Dec 2025 15:31:18 +0530 Subject: [PATCH] refactor: restructure in entirety --- .gitignore | 3 + Dockerfile | 6 +- Makefile | 6 +- cmd/{api => server}/main.go | 4 +- db/dev.db | Bin 57344 -> 57344 bytes go.mod | 3 +- internal/api/handlers.go | 28 - internal/auth/store.go | 69 --- internal/database/buyer.go | 63 ++ internal/database/database.go | 569 ------------------ internal/database/invoice.go | 99 +++ internal/database/product.go | 82 +++ internal/database/service.go | 141 +++++ internal/database/user.go | 58 ++ internal/gst/calculator_test.go | 122 ---- internal/handler/account.go | 108 ++++ internal/{web => handler}/auth.go | 78 ++- internal/{web => handler}/billing.go | 46 +- internal/{web => handler}/buyer.go | 13 +- internal/handler/health.go | 23 + internal/{web => handler}/home.go | 10 +- internal/{web => handler}/invoices.go | 7 +- internal/handler/modal.go | 53 ++ internal/{web => handler}/product.go | 18 +- internal/{auth => logic}/auth.go | 85 +-- internal/{gst => logic}/calculator.go | 2 +- internal/models/models.go | 46 ++ internal/server/routes.go | 50 +- internal/server/routes_test.go | 34 -- internal/server/server.go | 12 +- internal/{web => view}/render.go | 2 +- internal/web/account.go | 107 ---- internal/web/assets/js/dialog.js | 249 -------- {internal/web => web}/assets/css/output.css | 0 .../web => web}/assets/css/output.css.map | 0 {internal/web => web}/assets/js/htmx.min.js | 0 {internal/web => web}/assets/scss/_base.scss | 0 .../web => web}/assets/scss/_components.scss | 0 {internal/web => web}/assets/scss/_print.scss | 0 .../web => web}/assets/scss/_utilities.scss | 0 .../web => web}/assets/scss/_variables.scss | 0 {internal/web => web}/assets/scss/main.scss | 0 {internal/web => web}/efs.go | 0 43 files changed, 854 insertions(+), 1342 deletions(-) rename cmd/{api => server}/main.go (92%) delete mode 100644 internal/api/handlers.go delete mode 100644 internal/auth/store.go create mode 100644 internal/database/buyer.go delete mode 100644 internal/database/database.go create mode 100644 internal/database/invoice.go create mode 100644 internal/database/product.go create mode 100644 internal/database/service.go create mode 100644 internal/database/user.go delete mode 100644 internal/gst/calculator_test.go create mode 100644 internal/handler/account.go rename internal/{web => handler}/auth.go (60%) rename internal/{web => handler}/billing.go (93%) rename internal/{web => handler}/buyer.go (87%) create mode 100644 internal/handler/health.go rename internal/{web => handler}/home.go (75%) rename internal/{web => handler}/invoices.go (78%) create mode 100644 internal/handler/modal.go rename internal/{web => handler}/product.go (92%) rename internal/{auth => logic}/auth.go (60%) rename internal/{gst => logic}/calculator.go (99%) create mode 100644 internal/models/models.go delete mode 100644 internal/server/routes_test.go rename internal/{web => view}/render.go (96%) delete mode 100644 internal/web/account.go delete mode 100644 internal/web/assets/js/dialog.js rename {internal/web => web}/assets/css/output.css (100%) rename {internal/web => web}/assets/css/output.css.map (100%) rename {internal/web => web}/assets/js/htmx.min.js (100%) rename {internal/web => web}/assets/scss/_base.scss (100%) rename {internal/web => web}/assets/scss/_components.scss (100%) rename {internal/web => web}/assets/scss/_print.scss (100%) rename {internal/web => web}/assets/scss/_utilities.scss (100%) rename {internal/web => web}/assets/scss/_variables.scss (100%) rename {internal/web => web}/assets/scss/main.scss (100%) rename {internal/web => web}/efs.go (100%) 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 7570bd5fb6ea2575171fffec5ec86f329056f57f..443678787f3d457b048f50d971e9fb4a1a449a39 100644 GIT binary patch delta 215 zcmZoTz}#?vd4e>f&_o$$Mxl)fOZ2&z*_s%5Zg8$;YuY%mgw3u^gPA>4w_DoSI4Q}} zJk88FS=Y?c%tY5D*}y_K(b6DE*F4GC+|oSN)W{;q$S>Gc-z7C!*T}%gRNuhBc=Kz^ z$4p#i3I-NdMut{~#+%n#hO==o^S3eZ-{5cKU%y#Upn_jhgE^Fu7}J?IulElX0BYaK i!2go}BLB|Kf(onoCtu7LXJp=dF<)8$NWHBWP5=N88a%Q9 delta 87 zcmZoTz}#?vd4e>fz(g5mMuCk9OY}LIIM*`p+~8chSx{gh=jOGR;cOgC{OcL`Z}6|* rEU2)UfAf0(PywLOMF#$t{1<^jNBJl3%opGMBA-Qok!kbWdf@~B!WbP6 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, `
From:
%s
`, invoice.CompanyDetails) } - if err := PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil { + if err := view.PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil { return err } diff --git a/internal/web/buyer.go b/internal/handler/buyer.go similarity index 87% rename from internal/web/buyer.go rename to internal/handler/buyer.go index 90b2bac..759cd0b 100644 --- a/internal/web/buyer.go +++ b/internal/handler/buyer.go @@ -1,5 +1,6 @@ -package web +package handler +import "billit/internal/view" import ( "billit/internal/database" "net/http" @@ -22,14 +23,14 @@ func (h *BuyerHandlers) BuyerListHandler(c echo.Context) error { userID := getUserID(c) buyers, err := h.db.GetAllBuyerDetails(userID) if err != nil { - return RenderServerError(c, "Failed to load buyers. Please try again.") + return view.RenderServerError(c, "Failed to load buyers. Please try again.") } - return Render(c, BuyerListPage(buyers)) + return view.Render(c, view.BuyerListPage(buyers)) } // BuyerCreatePageHandler renders the /buyer/create form page func (h *BuyerHandlers) BuyerCreatePageHandler(c echo.Context) error { - return Render(c, BuyerCreatePage()) + return view.Render(c, view.BuyerCreatePage()) } // BuyerEditPageHandler renders the /buyer/edit/:id form page @@ -39,9 +40,9 @@ func (h *BuyerHandlers) BuyerEditPageHandler(c echo.Context) error { buyer, err := h.db.GetBuyerDetails(id, userID) if err != nil || buyer == nil { - return RenderNotFound(c, "Buyer not found or you don't have access to it.") + return view.RenderNotFound(c, "Buyer not found or you don't have access to it.") } - return Render(c, BuyerEditPage(*buyer)) + return view.Render(c, view.BuyerEditPage(*buyer)) } // BuyerCreateHandler handles POST /buyer/create diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..04a5a28 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,23 @@ +package handler + +import ( + "billit/internal/database" + "net/http" + + "github.com/labstack/echo/v4" +) + +// HealthHandlers holds dependencies for health checks +type HealthHandlers struct { + db database.Service +} + +// NewHealthHandlers creates health handlers with db access +func NewHealthHandlers(db database.Service) *HealthHandlers { + return &HealthHandlers{db: db} +} + +// HealthHandler returns the health status +func (h *HealthHandlers) HealthHandler(c echo.Context) error { + return c.JSON(http.StatusOK, h.db.Health()) +} diff --git a/internal/web/home.go b/internal/handler/home.go similarity index 75% rename from internal/web/home.go rename to internal/handler/home.go index 6553e65..ac8d7b7 100644 --- a/internal/web/home.go +++ b/internal/handler/home.go @@ -1,5 +1,7 @@ -package web +package handler +import "billit/internal/models" +import "billit/internal/view" import ( "billit/internal/database" @@ -24,14 +26,14 @@ func (h *HomeHandlers) HomePageHandler(c echo.Context) error { // Get recent products (last 5) recentProducts, err := h.db.GetRecentProducts(userID, 5) if err != nil { - recentProducts = []database.Product{} + recentProducts = []models.Product{} } // Get recent invoices (last 5) recentInvoices, err := h.db.GetRecentInvoices(userID, 5) if err != nil { - recentInvoices = []database.Invoice{} + recentInvoices = []models.Invoice{} } - return Render(c, HomePage(userEmail, recentProducts, recentInvoices)) + return view.Render(c, view.HomePage(userEmail, recentProducts, recentInvoices)) } diff --git a/internal/web/invoices.go b/internal/handler/invoices.go similarity index 78% rename from internal/web/invoices.go rename to internal/handler/invoices.go index de93240..b456f85 100644 --- a/internal/web/invoices.go +++ b/internal/handler/invoices.go @@ -1,5 +1,6 @@ -package web +package handler +import "billit/internal/view" import ( "billit/internal/database" "net/http" @@ -26,7 +27,7 @@ func (h *InvoicesHandlers) InvoicesListHandler(c echo.Context) error { invoices, err := h.db.GetAllInvoices(userID) if err != nil { - return RenderServerError(c, "Failed to load invoices. Please try again.") + return view.RenderServerError(c, "Failed to load invoices. Please try again.") } - return Render(c, InvoicesPage(invoices)) + return view.Render(c, view.InvoicesPage(invoices)) } diff --git a/internal/handler/modal.go b/internal/handler/modal.go new file mode 100644 index 0000000..12ded1c --- /dev/null +++ b/internal/handler/modal.go @@ -0,0 +1,53 @@ +package handler + +import ( + "billit/internal/view" + "strings" + + "github.com/labstack/echo/v4" +) + +// ModalHandlers holds dependencies +type ModalHandlers struct{} + +// NewModalHandlers creates new handlers +func NewModalHandlers() *ModalHandlers { + return &ModalHandlers{} +} + +// ConfirmHandler renders the confirmation modal +func (h *ModalHandlers) ConfirmHandler(c echo.Context) error { + title := c.QueryParam("title") + if title == "" { + title = "Confirm Action" + } + + message := c.QueryParam("message") + if message == "" { + message = "Are you sure you want to proceed?" + } + + confirmText := c.QueryParam("confirm_text") + if confirmText == "" { + confirmText = "Confirm" + } + + url := c.QueryParam("url") + method := c.QueryParam("method") + if method == "" { + method = "delete" // Default to delete + } + + target := c.QueryParam("target") + + props := view.ModalProps{ + Title: title, + Message: message, + ConfirmText: confirmText, + ConfirmURL: url, + Method: strings.ToLower(method), + Target: target, + } + + return view.Render(c, view.Modal(props)) +} diff --git a/internal/web/product.go b/internal/handler/product.go similarity index 92% rename from internal/web/product.go rename to internal/handler/product.go index 2c5045d..f107eed 100644 --- a/internal/web/product.go +++ b/internal/handler/product.go @@ -1,5 +1,7 @@ -package web +package handler +import "billit/internal/models" +import "billit/internal/view" import ( "billit/internal/database" "net/http" @@ -31,14 +33,14 @@ func (h *ProductHandlers) ProductListHandler(c echo.Context) error { userID := getUserID(c) products, err := h.db.GetAllProducts(userID) if err != nil { - return RenderServerError(c, "Failed to load products. Please try again.") + return view.RenderServerError(c, "Failed to load products. Please try again.") } - return Render(c, ProductListPage(products)) + return view.Render(c, view.ProductListPage(products)) } // ProductCreatePageHandler renders the /product/create form page func (h *ProductHandlers) ProductCreatePageHandler(c echo.Context) error { - return Render(c, ProductCreatePage()) + return view.Render(c, view.ProductCreatePage()) } // ProductEditPageHandler renders the /product/edit/:sku form page @@ -48,9 +50,9 @@ func (h *ProductHandlers) ProductEditPageHandler(c echo.Context) error { product, err := h.db.GetProductBySKU(sku, userID) if err != nil || product == nil { - return RenderNotFound(c, "Product not found or you don't have access to it.") + return view.RenderNotFound(c, "Product not found or you don't have access to it.") } - return Render(c, ProductEditPage(*product)) + return view.Render(c, view.ProductEditPage(*product)) } // ProductCreateHandler handles POST /product/create @@ -115,7 +117,7 @@ func (h *ProductHandlers) ProductCreateHandler(c echo.Context) error { unit = "pcs" } - product := database.Product{ + product := models.Product{ SKU: sku, Name: name, HSNCode: hsn, @@ -194,7 +196,7 @@ func (h *ProductHandlers) ProductUpdateHandler(c echo.Context) error { unit = "pcs" } - product := database.Product{ + product := models.Product{ SKU: sku, Name: name, HSNCode: hsn, diff --git a/internal/auth/auth.go b/internal/logic/auth.go similarity index 60% rename from internal/auth/auth.go rename to internal/logic/auth.go index 8a38724..11c19c5 100644 --- a/internal/auth/auth.go +++ b/internal/logic/auth.go @@ -1,10 +1,10 @@ -package auth +package logic import ( + "billit/internal/models" "crypto/rand" "encoding/hex" "errors" - "net/http" "os" "time" @@ -19,24 +19,12 @@ var ( ErrUserExists = errors.New("user already exists") ) -// Config holds auth configuration -type Config struct { +// AuthConfig holds auth configuration +type AuthConfig struct { JWTSecret []byte - CookieDomain string - CookieSecure bool TokenDuration time.Duration } -// User represents an authenticated user -type User struct { - ID string `json:"id"` - Email string `json:"email"` - Password string `json:"-"` // Never expose password hash - CompanyDetails string `json:"company_details"` - BankDetails string `json:"bank_details"` - CreatedAt string `json:"created_at"` -} - // Claims represents JWT claims type Claims struct { UserID string `json:"user_id"` @@ -44,21 +32,21 @@ type Claims struct { jwt.RegisteredClaims } -// Service handles authentication -type Service struct { - config Config +// AuthService handles authentication logic +type AuthService struct { + config AuthConfig users UserStore } -// UserStore interface for user persistence +// UserStore interface for user persistence (subset of database.Service) type UserStore interface { - CreateUser(email, passwordHash string) (*User, error) - GetUserByEmail(email string) (*User, error) - GetUserByID(id string) (*User, error) + CreateUser(email, passwordHash string) (*models.User, error) + GetUserByEmail(email string) (*models.User, error) + GetUserByID(id string) (*models.User, error) } -// NewService creates a new auth service -func NewService(users UserStore) *Service { +// 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) @@ -67,14 +55,9 @@ func NewService(users UserStore) *Service { secret = hex.EncodeToString(b) } - domain := os.Getenv("COOKIE_DOMAIN") - secure := os.Getenv("COOKIE_SECURE") == "true" - - return &Service{ - config: Config{ + return &AuthService{ + config: AuthConfig{ JWTSecret: []byte(secret), - CookieDomain: domain, - CookieSecure: secure, TokenDuration: 24 * time.Hour, }, users: users, @@ -98,7 +81,7 @@ func CheckPassword(password, hash string) bool { } // Register creates a new user account -func (s *Service) Register(email, password string) (*User, error) { +func (s *AuthService) Register(email, password string) (*models.User, error) { // Check if user exists existing, _ := s.users.GetUserByEmail(email) if existing != nil { @@ -120,7 +103,7 @@ func (s *Service) Register(email, password string) (*User, error) { } // Login authenticates a user and returns a JWT token -func (s *Service) Login(email, password string) (string, error) { +func (s *AuthService) Login(email, password string) (string, error) { user, err := s.users.GetUserByEmail(email) if err != nil || user == nil { return "", ErrInvalidCredentials @@ -134,7 +117,7 @@ func (s *Service) Login(email, password string) (string, error) { } // generateToken creates a new JWT token for a user -func (s *Service) generateToken(user *User) (string, error) { +func (s *AuthService) generateToken(user *models.User) (string, error) { now := time.Now() claims := &Claims{ UserID: user.ID, @@ -153,7 +136,7 @@ func (s *Service) generateToken(user *User) (string, error) { } // ValidateToken validates a JWT token and returns the claims -func (s *Service) ValidateToken(tokenString string) (*Claims, error) { +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 @@ -172,36 +155,8 @@ func (s *Service) ValidateToken(tokenString string) (*Claims, error) { return nil, ErrInvalidToken } -// CreateAuthCookie creates an HTTP-only secure cookie for the token -func (s *Service) CreateAuthCookie(token string) *http.Cookie { - return &http.Cookie{ - Name: "auth_token", - Value: token, - Path: "/", - Domain: s.config.CookieDomain, - MaxAge: int(s.config.TokenDuration.Seconds()), - HttpOnly: true, // Prevents JavaScript access - Secure: s.config.CookieSecure, // Only send over HTTPS in production - SameSite: http.SameSiteStrictMode, - } -} - -// ClearAuthCookie returns a cookie that clears the auth token -func (s *Service) ClearAuthCookie() *http.Cookie { - return &http.Cookie{ - Name: "auth_token", - Value: "", - Path: "/", - Domain: s.config.CookieDomain, - MaxAge: -1, - HttpOnly: true, - Secure: s.config.CookieSecure, - SameSite: http.SameSiteStrictMode, - } -} - // GetUserFromToken retrieves the user from a valid token -func (s *Service) GetUserFromToken(tokenString string) (*User, error) { +func (s *AuthService) GetUserFromToken(tokenString string) (*models.User, error) { claims, err := s.ValidateToken(tokenString) if err != nil { return nil, err diff --git a/internal/gst/calculator.go b/internal/logic/calculator.go similarity index 99% rename from internal/gst/calculator.go rename to internal/logic/calculator.go index 107aa3a..e9c7c1d 100644 --- a/internal/gst/calculator.go +++ b/internal/logic/calculator.go @@ -1,4 +1,4 @@ -package gst +package logic import ( "math" diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..380b202 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,46 @@ +package models + +// 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"` +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 130bfc1..269b16b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,12 +1,11 @@ package server import ( + "billit/internal/handler" + "billit/internal/logic" + "billit/internal/view" "net/http" - "billit/internal/api" - "billit/internal/auth" - "billit/internal/web" - "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) @@ -25,23 +24,24 @@ func (s *Server) RegisterRoutes() http.Handler { })) // Static files - fileServer := http.FileServer(http.FS(web.Files)) - e.GET("/assets/*", echo.WrapHandler(fileServer)) + if s.assetsFS != nil { + fileServer := http.FileServer(http.FS(s.assetsFS)) + e.GET("/assets/*", echo.WrapHandler(fileServer)) + } // ======================================== // Auth Setup // ======================================== - userStore := auth.NewDBUserStore(s.db) - authService := auth.NewService(userStore) - authHandlers := web.NewAuthHandlers(authService) + authService := logic.NewAuthService(s.db) + authHandlers := handler.NewAuthHandlers(authService) // ======================================== - // API Routes (JSON responses) - Health only, products/invoice via web UI + // API Routes (JSON responses) - Health only // ======================================== - apiHandlers := api.NewHandlers(s.db) + healthHandlers := handler.NewHealthHandlers(s.db) apiGroup := e.Group("/api") { - apiGroup.GET("/health", apiHandlers.HealthHandler) + apiGroup.GET("/health", healthHandlers.HealthHandler) } // ======================================== @@ -60,17 +60,17 @@ func (s *Server) RegisterRoutes() http.Handler { protected.Use(authHandlers.AuthMiddleware) // Home - homeHandlers := web.NewHomeHandlers(s.db) + homeHandlers := handler.NewHomeHandlers(s.db) protected.GET("/home", homeHandlers.HomePageHandler) // Account routes - accountHandlers := web.NewAccountHandlers(s.db, authService) + accountHandlers := handler.NewAccountHandlers(s.db, authService) protected.GET("/account", accountHandlers.AccountPageHandler) protected.POST("/account/details", accountHandlers.UpdateDetailsHandler) protected.POST("/account/password", accountHandlers.ChangePasswordHandler) // Buyer routes - buyerHandlers := web.NewBuyerHandlers(s.db) + buyerHandlers := handler.NewBuyerHandlers(s.db) protected.GET("/buyer", buyerHandlers.BuyerListHandler) protected.GET("/buyer/create", buyerHandlers.BuyerCreatePageHandler) protected.POST("/buyer/create", buyerHandlers.BuyerCreateHandler) @@ -79,11 +79,15 @@ func (s *Server) RegisterRoutes() http.Handler { protected.DELETE("/buyer/:id", buyerHandlers.BuyerDeleteHandler) // Invoices list - invoicesHandlers := web.NewInvoicesHandlers(s.db) + invoicesHandlers := handler.NewInvoicesHandlers(s.db) protected.GET("/invoice", invoicesHandlers.InvoicesListHandler) + // Modal routes + modalHandlers := handler.NewModalHandlers() + protected.GET("/modal/confirm", modalHandlers.ConfirmHandler) + // Product routes (web UI) - productHandlers := web.NewProductHandlers(s.db) + productHandlers := handler.NewProductHandlers(s.db) protected.GET("/product", productHandlers.ProductListHandler) protected.GET("/product/create", productHandlers.ProductCreatePageHandler) protected.POST("/product/create", productHandlers.ProductCreateHandler) @@ -92,7 +96,7 @@ func (s *Server) RegisterRoutes() http.Handler { protected.DELETE("/product/:sku", productHandlers.ProductDeleteHandler) // Billing routes (web UI) - billingHandlers := web.NewBillingHandlers(s.db) + billingHandlers := handler.NewBillingHandlers(s.db) protected.GET("/billing", billingHandlers.BillingPageHandler) protected.POST("/billing/calculate", billingHandlers.CalculateBillHandler) protected.POST("/billing/generate", billingHandlers.GenerateBillHandler) @@ -102,17 +106,17 @@ func (s *Server) RegisterRoutes() http.Handler { protected.GET("/invoice/:id", billingHandlers.ShowInvoiceHandler) // Legacy health check (kept for backward compatibility) - e.GET("/health", apiHandlers.HealthHandler) + e.GET("/health", healthHandlers.HealthHandler) // Custom 404 handler for Echo HTTP errors e.HTTPErrorHandler = func(err error, c echo.Context) { if he, ok := err.(*echo.HTTPError); ok { switch he.Code { case http.StatusNotFound: - _ = web.RenderNotFound(c, "") + _ = view.RenderNotFound(c, "") return case http.StatusInternalServerError: - _ = web.RenderServerError(c, "") + _ = view.RenderServerError(c, "") return } } @@ -122,8 +126,8 @@ func (s *Server) RegisterRoutes() http.Handler { // Catch-all for undefined routes (must be last) e.RouteNotFound("/*", func(c echo.Context) error { - return web.RenderNotFound(c, "") + return view.RenderNotFound(c, "") }) return e -} +} \ No newline at end of file diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go deleted file mode 100644 index c587bc2..0000000 --- a/internal/server/routes_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package server - -import ( - "net/http" - "net/http/httptest" - "testing" - - "billit/internal/database" - - "github.com/labstack/echo/v4" -) - -func TestHomeRoute(t *testing.T) { - // Create a minimal server with db for testing - db := database.New() - s := &Server{db: db} - handler := s.RegisterRoutes() - - req := httptest.NewRequest(http.MethodGet, "/", nil) - resp := httptest.NewRecorder() - handler.ServeHTTP(resp, req) - - if resp.Code != http.StatusOK { - t.Errorf("home route wrong status code = %v, want %v", resp.Code, http.StatusOK) - } -} - -func TestRouterSetup(t *testing.T) { - // Test that Echo router can be set up without panic - e := echo.New() - if e == nil { - t.Error("failed to create echo instance") - } -} diff --git a/internal/server/server.go b/internal/server/server.go index a20f784..e3ed268 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,20 +10,22 @@ import ( _ "github.com/joho/godotenv/autoload" "billit/internal/database" + "io/fs" ) type Server struct { port int - db database.Service + db database.Service + assetsFS fs.FS } -func NewServer() *http.Server { +func NewServer(assetsFS fs.FS) *http.Server { port, _ := strconv.Atoi(os.Getenv("PORT")) NewServer := &Server{ - port: port, - - db: database.New(), + port: port, + db: database.New(), + assetsFS: assetsFS, } // Declare Server config diff --git a/internal/web/render.go b/internal/view/render.go similarity index 96% rename from internal/web/render.go rename to internal/view/render.go index 3bcb007..b42e13d 100644 --- a/internal/web/render.go +++ b/internal/view/render.go @@ -1,4 +1,4 @@ -package web +package view import ( "github.com/a-h/templ" diff --git a/internal/web/account.go b/internal/web/account.go deleted file mode 100644 index 98a7251..0000000 --- a/internal/web/account.go +++ /dev/null @@ -1,107 +0,0 @@ -package web - -import ( - "billit/internal/auth" - "billit/internal/database" - "net/http" - - "github.com/labstack/echo/v4" -) - -// AccountHandlers holds references for account operations -type AccountHandlers struct { - db database.Service - auth *auth.Service -} - -// NewAccountHandlers creates handlers with db and auth access -func NewAccountHandlers(db database.Service, authService *auth.Service) *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 RenderServerError(c, "Failed to load account details.") - } - - return Render(c, 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 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 Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update details")) - } - - return Render(c, 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 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 !auth.CheckPassword(currentPassword, user.Password) { - return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Current password is incorrect")) - } - - // Validate new password - if len(newPassword) < 8 { - return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New password must be at least 8 characters")) - } - - if newPassword != confirmPassword { - return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New passwords do not match")) - } - - // Hash new password - hash, err := auth.HashPassword(newPassword) - if err != nil { - return Render(c, 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 Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password")) - } - - return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "Password changed successfully", "")) -} diff --git a/internal/web/assets/js/dialog.js b/internal/web/assets/js/dialog.js deleted file mode 100644 index c37caa8..0000000 --- a/internal/web/assets/js/dialog.js +++ /dev/null @@ -1,249 +0,0 @@ -// Dialog component for Billit -// Replaces browser confirm/alert dialogs with custom styled modals - -(function() { - 'use strict'; - - // Dialog state - let currentResolve = null; - let currentElement = null; - - // Create dialog HTML structure - function createDialogElement() { - const dialog = document.createElement('div'); - dialog.id = 'dialog'; - dialog.className = 'dialog-overlay'; - dialog.innerHTML = ` -
-
-

-
-
-

-
- -
- `; - document.body.appendChild(dialog); - - // Event listeners - dialog.querySelector('.dialog-cancel').addEventListener('click', () => closeDialog(false)); - dialog.querySelector('.dialog-confirm').addEventListener('click', () => closeDialog(true)); - dialog.addEventListener('click', (e) => { - if (e.target === dialog) closeDialog(false); - }); - - // Escape key closes dialog - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && dialog.classList.contains('dialog-open')) { - closeDialog(false); - } - }); - - return dialog; - } - - // Get or create dialog element - function getDialog() { - return document.getElementById('dialog') || createDialogElement(); - } - - // Open dialog with options - function openDialog(options) { - const dialog = getDialog(); - const title = options.title || 'Confirm'; - const message = options.message || 'Are you sure?'; - const confirmText = options.confirmText || 'Confirm'; - const cancelText = options.cancelText || 'Cancel'; - const confirmClass = options.confirmClass || 'btn-danger'; - const html = options.html || null; - const wide = options.wide || false; - const allowClose = options.allowClose !== false; - - dialog.querySelector('.dialog-title').textContent = title; - - // Support HTML content - if (html) { - dialog.querySelector('.dialog-body').innerHTML = html; - } else { - dialog.querySelector('.dialog-body').innerHTML = '

' + escapeHtml(message) + '

'; - } - - dialog.querySelector('.dialog-confirm').textContent = confirmText; - dialog.querySelector('.dialog-confirm').className = 'btn ' + confirmClass + ' dialog-confirm'; - dialog.querySelector('.dialog-cancel').textContent = cancelText; - - // Show/hide cancel button for alert-style dialogs - dialog.querySelector('.dialog-cancel').style.display = options.showCancel !== false ? '' : 'none'; - - // Wide mode for larger content - dialog.querySelector('.dialog-box').style.maxWidth = wide ? '600px' : '400px'; - - // Store allowClose setting - dialog.dataset.allowClose = allowClose; - - dialog.classList.add('dialog-open'); - dialog.querySelector('.dialog-confirm').focus(); - - return new Promise((resolve) => { - currentResolve = resolve; - }); - } - - // Close dialog - function closeDialog(result) { - const dialog = getDialog(); - - // Check if closing is allowed (for disclaimer) - if (!result && dialog.dataset.allowClose === 'false') { - return; - } - - dialog.classList.remove('dialog-open'); - - if (currentResolve) { - currentResolve(result); - currentResolve = null; - } - - // If there's a pending HTMX request, trigger it - if (result && currentElement) { - htmx.trigger(currentElement, 'confirmed'); - } - currentElement = null; - } - - // Escape HTML for safe rendering - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - // Public API - window.Dialog = { - confirm: function(options) { - if (typeof options === 'string') { - options = { message: options }; - } - return openDialog({ ...options, showCancel: true }); - }, - - alert: function(options) { - if (typeof options === 'string') { - options = { message: options }; - } - return openDialog({ - ...options, - showCancel: false, - confirmText: options.confirmText || 'OK', - confirmClass: options.confirmClass || 'btn-primary' - }); - }, - - // Custom dialog with HTML content - custom: function(options) { - return openDialog(options); - } - }; - - // HTMX integration: intercept hx-confirm and use custom dialog - // Elements can customize the dialog with data attributes: - // data-dialog-title="Custom Title" - // data-dialog-confirm="Button Text" - // data-dialog-class="btn-danger" (or btn-primary, etc.) - // If no data-dialog-* attributes are present, uses browser default confirm - document.addEventListener('htmx:confirm', function(e) { - const element = e.detail.elt; - - // Check if element wants custom dialog (has any data-dialog-* attribute) - const hasCustomDialog = element.dataset.dialogTitle || - element.dataset.dialogConfirm || - element.dataset.dialogClass; - - if (!hasCustomDialog) { - return; // Let default browser confirm handle it - } - - // Prevent default browser confirm - e.preventDefault(); - - const message = e.detail.question; - const title = element.dataset.dialogTitle || 'Confirm'; - const confirmText = element.dataset.dialogConfirm || 'Confirm'; - const confirmClass = element.dataset.dialogClass || 'btn-primary'; - - // Store element for later - currentElement = element; - - Dialog.confirm({ - title: title, - message: message, - confirmText: confirmText, - confirmClass: confirmClass - }).then(function(confirmed) { - if (confirmed) { - // Issue the request - e.detail.issueRequest(true); - } - currentElement = null; - }); - }); - - // Disclaimer dialog - show on first visit - function showDisclaimer() { - const DISCLAIMER_KEY = 'billit_disclaimer_accepted'; - - // Check if already accepted - if (localStorage.getItem(DISCLAIMER_KEY)) { - return; - } - - const disclaimerHTML = ` -
-

- 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. -

-
- `; - - Dialog.custom({ - title: '⚠️ GENERAL USE & NO LIABILITY DISCLAIMER', - html: disclaimerHTML, - confirmText: 'I Understand & Accept', - confirmClass: 'btn-primary', - showCancel: false, - wide: true, - allowClose: false - }).then(function(accepted) { - if (accepted) { - localStorage.setItem(DISCLAIMER_KEY, Date.now().toString()); - } - }); - } - - // Show disclaimer when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', showDisclaimer); - } else { - showDisclaimer(); - } - -})(); diff --git a/internal/web/assets/css/output.css b/web/assets/css/output.css similarity index 100% rename from internal/web/assets/css/output.css rename to web/assets/css/output.css diff --git a/internal/web/assets/css/output.css.map b/web/assets/css/output.css.map similarity index 100% rename from internal/web/assets/css/output.css.map rename to web/assets/css/output.css.map diff --git a/internal/web/assets/js/htmx.min.js b/web/assets/js/htmx.min.js similarity index 100% rename from internal/web/assets/js/htmx.min.js rename to web/assets/js/htmx.min.js diff --git a/internal/web/assets/scss/_base.scss b/web/assets/scss/_base.scss similarity index 100% rename from internal/web/assets/scss/_base.scss rename to web/assets/scss/_base.scss diff --git a/internal/web/assets/scss/_components.scss b/web/assets/scss/_components.scss similarity index 100% rename from internal/web/assets/scss/_components.scss rename to web/assets/scss/_components.scss diff --git a/internal/web/assets/scss/_print.scss b/web/assets/scss/_print.scss similarity index 100% rename from internal/web/assets/scss/_print.scss rename to web/assets/scss/_print.scss diff --git a/internal/web/assets/scss/_utilities.scss b/web/assets/scss/_utilities.scss similarity index 100% rename from internal/web/assets/scss/_utilities.scss rename to web/assets/scss/_utilities.scss diff --git a/internal/web/assets/scss/_variables.scss b/web/assets/scss/_variables.scss similarity index 100% rename from internal/web/assets/scss/_variables.scss rename to web/assets/scss/_variables.scss diff --git a/internal/web/assets/scss/main.scss b/web/assets/scss/main.scss similarity index 100% rename from internal/web/assets/scss/main.scss rename to web/assets/scss/main.scss diff --git a/internal/web/efs.go b/web/efs.go similarity index 100% rename from internal/web/efs.go rename to web/efs.go