quite a lot of things
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_PATH=./db/dev.db
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
# Generate a secure random secret: openssl rand -hex 32
|
||||||
|
JWT_SECRET=change_me_to_a_secure_random_string
|
||||||
|
|
||||||
|
# Cookie Settings
|
||||||
|
# Set your domain for production (e.g., .example.com)
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
# Set to true when using HTTPS
|
||||||
|
COOKIE_SECURE=false
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,4 +36,7 @@ main
|
|||||||
# Tailwind CSS
|
# Tailwind CSS
|
||||||
cmd/web/assets/css/output.css
|
cmd/web/assets/css/output.css
|
||||||
tailwindcss
|
tailwindcss
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Docker image tarball
|
||||||
|
image.tar
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -1,23 +1,25 @@
|
|||||||
FROM golang:1.24.4-alpine AS build
|
FROM golang:1.25.1-alpine AS build
|
||||||
RUN apk add --no-cache curl libstdc++ libgcc alpine-sdk
|
RUN apk add --no-cache curl libstdc++ libgcc alpine-sdk npm
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install sass for SCSS compilation
|
||||||
|
RUN npm install -g sass
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go install github.com/a-h/templ/cmd/templ@latest && \
|
|
||||||
templ generate && \
|
# Compile SCSS to CSS
|
||||||
curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64-musl -o tailwindcss && \
|
RUN sass internal/web/assets/scss/main.scss internal/web/assets/css/output.css --style=compressed
|
||||||
chmod +x tailwindcss && \
|
|
||||||
./tailwindcss -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go
|
RUN CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go
|
||||||
|
|
||||||
FROM alpine:3.20.1 AS prod
|
FROM alpine:3.20.1 AS prod
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/main /app/main
|
COPY --from=build /app/main /app/main
|
||||||
|
COPY --from=build /app/internal/web/assets /app/internal/web/assets
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
CMD ["./main"]
|
CMD ["./main"]
|
||||||
|
|
||||||
|
|||||||
122
Makefile
122
Makefile
@@ -1,78 +1,66 @@
|
|||||||
# Simple Makefile for a Go project
|
# ============================================
|
||||||
|
# BILLIT - Makefile
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
.PHONY: all build run dev clean scss scss-watch test docker-build docker-run
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
APP_NAME := billit
|
||||||
|
MAIN_PATH := ./cmd/api
|
||||||
|
SCSS_DIR := internal/web/assets/scss
|
||||||
|
CSS_DIR := internal/web/assets/css
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
all: scss build
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
all: build test
|
build:
|
||||||
templ-install:
|
@echo "Building $(APP_NAME)..."
|
||||||
@if ! command -v templ > /dev/null; then \
|
go build -o bin/$(APP_NAME) $(MAIN_PATH)
|
||||||
read -p "Go's 'templ' is not installed on your machine. Do you want to install it? [Y/n] " choice; \
|
|
||||||
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
|
|
||||||
go install github.com/a-h/templ/cmd/templ@latest; \
|
|
||||||
if [ ! -x "$$(command -v templ)" ]; then \
|
|
||||||
echo "templ installation failed. Exiting..."; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
else \
|
|
||||||
echo "You chose not to install templ. Exiting..."; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
fi
|
|
||||||
tailwind-install:
|
|
||||||
|
|
||||||
@if [ ! -f tailwindcss ]; then curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-x64 -o tailwindcss; fi
|
|
||||||
@chmod +x tailwindcss
|
|
||||||
|
|
||||||
build: tailwind-install templ-install
|
|
||||||
@echo "Building..."
|
|
||||||
@templ generate
|
|
||||||
@./tailwindcss -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css
|
|
||||||
@CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go
|
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
run:
|
run: scss
|
||||||
@go run cmd/api/main.go
|
@echo "Running $(APP_NAME)..."
|
||||||
# Create DB container
|
go run $(MAIN_PATH)
|
||||||
docker-run:
|
|
||||||
@if docker compose up --build 2>/dev/null; then \
|
|
||||||
: ; \
|
|
||||||
else \
|
|
||||||
echo "Falling back to Docker Compose V1"; \
|
|
||||||
docker-compose up --build; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Shutdown DB container
|
# Development mode with hot reload
|
||||||
docker-down:
|
dev:
|
||||||
@if docker compose down 2>/dev/null; then \
|
@echo "Starting development server..."
|
||||||
: ; \
|
air
|
||||||
else \
|
|
||||||
echo "Falling back to Docker Compose V1"; \
|
|
||||||
docker-compose down; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test the application
|
# Compile SCSS to CSS
|
||||||
test:
|
scss:
|
||||||
@echo "Testing..."
|
@echo "Compiling SCSS..."
|
||||||
@go test ./... -v
|
sass $(SCSS_DIR)/main.scss $(CSS_DIR)/output.css --style=compressed
|
||||||
|
|
||||||
# Clean the binary
|
# Watch SCSS for changes
|
||||||
|
scss-watch:
|
||||||
|
@echo "Watching SCSS for changes..."
|
||||||
|
sass $(SCSS_DIR)/main.scss $(CSS_DIR)/output.css --style=compressed --watch
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning..."
|
@echo "Cleaning..."
|
||||||
@rm -f main
|
rm -rf bin/
|
||||||
|
rm -f $(CSS_DIR)/output.css
|
||||||
|
rm -f $(CSS_DIR)/output.css.map
|
||||||
|
|
||||||
# Live Reload
|
# Run tests
|
||||||
watch:
|
test:
|
||||||
@if command -v air > /dev/null; then \
|
@echo "Running tests..."
|
||||||
air; \
|
go test ./...
|
||||||
echo "Watching...";\
|
|
||||||
else \
|
|
||||||
read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \
|
|
||||||
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
|
|
||||||
go install github.com/air-verse/air@latest; \
|
|
||||||
air; \
|
|
||||||
echo "Watching...";\
|
|
||||||
else \
|
|
||||||
echo "You chose not to install air. Exiting..."; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: all build run test clean watch tailwind-install templ-install
|
# Docker build
|
||||||
|
docker-build: scss
|
||||||
|
@echo "Building Docker image..."
|
||||||
|
docker compose -f compose.build.yml build
|
||||||
|
|
||||||
|
# Docker run
|
||||||
|
docker-run:
|
||||||
|
@echo "Running Docker container..."
|
||||||
|
docker compose up
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
release: scss
|
||||||
|
@echo "Building for production..."
|
||||||
|
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o bin/$(APP_NAME) $(MAIN_PATH)
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
templ Base() {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="h-screen">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
||||||
<title>Go Blueprint Hello</title>
|
|
||||||
<link href="assets/css/output.css" rel="stylesheet"/>
|
|
||||||
<script src="assets/js/htmx.min.js"></script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100">
|
|
||||||
<main class="max-w-sm mx-auto p-4">
|
|
||||||
{ children... }
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func HelloWebHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := r.FormValue("name")
|
|
||||||
component := HelloPost(name)
|
|
||||||
err = component.Render(r.Context(), w)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
log.Fatalf("Error rendering in HelloWebHandler: %e", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
templ HelloForm() {
|
|
||||||
@Base() {
|
|
||||||
<form hx-post="/hello" method="POST" hx-target="#hello-container">
|
|
||||||
<input class="bg-gray-200 text-black p-2 border border-gray-400 rounded-lg"id="name" name="name" type="text"/>
|
|
||||||
<button type="submit" class="bg-orange-500 hover:bg-orange-700 text-white py-2 px-4 rounded">Submit</button>
|
|
||||||
</form>
|
|
||||||
<div id="hello-container"></div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ HelloPost(name string) {
|
|
||||||
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
|
|
||||||
<p>Hello, { name }</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss"
|
|
||||||
11
compose.build.yml
Normal file
11
compose.build.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Build-only compose file
|
||||||
|
# Usage: docker compose -f compose.build.yml build
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: prod
|
||||||
|
image: billit:latest
|
||||||
|
platform: linux/amd64
|
||||||
18
compose.deploy.yml
Normal file
18
compose.deploy.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Deployment compose file
|
||||||
|
# Usage: docker compose -f compose.deploy.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: billit:latest
|
||||||
|
container_name: billit
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3020:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- db:/app/db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db:
|
||||||
|
driver: local
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: prod
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- ${PORT}:${PORT}
|
|
||||||
environment:
|
|
||||||
APP_ENV: ${APP_ENV}
|
|
||||||
PORT: ${PORT}
|
|
||||||
BLUEPRINT_DB_URL: ${BLUEPRINT_DB_URL}
|
|
||||||
volumes:
|
|
||||||
- sqlite_bp:/app/db
|
|
||||||
volumes:
|
|
||||||
sqlite_bp:
|
|
||||||
5
go.mod
5
go.mod
@@ -4,18 +4,21 @@ go 1.25.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.960
|
github.com/a-h/templ v0.3.960
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -2,8 +2,12 @@ github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
|
|||||||
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
@@ -18,6 +22,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
|
|||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
|||||||
28
internal/api/handlers.go
Normal file
28
internal/api/handlers.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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.
|
||||||
211
internal/auth/auth.go
Normal file
211
internal/auth/auth.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||||
|
ErrInvalidToken = errors.New("invalid or expired token")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrUserExists = errors.New("user already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds auth configuration
|
||||||
|
type Config 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"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service handles authentication
|
||||||
|
type Service struct {
|
||||||
|
config Config
|
||||||
|
users UserStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStore interface for user persistence
|
||||||
|
type UserStore interface {
|
||||||
|
CreateUser(email, passwordHash string) (*User, error)
|
||||||
|
GetUserByEmail(email string) (*User, error)
|
||||||
|
GetUserByID(id string) (*User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new auth service
|
||||||
|
func NewService(users UserStore) *Service {
|
||||||
|
secret := os.Getenv("JWT_SECRET")
|
||||||
|
if secret == "" {
|
||||||
|
// Generate a random secret if not provided (not recommended for production)
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
secret = hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := os.Getenv("COOKIE_DOMAIN")
|
||||||
|
secure := os.Getenv("COOKIE_SECURE") == "true"
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
config: Config{
|
||||||
|
JWTSecret: []byte(secret),
|
||||||
|
CookieDomain: domain,
|
||||||
|
CookieSecure: secure,
|
||||||
|
TokenDuration: 24 * time.Hour,
|
||||||
|
},
|
||||||
|
users: users,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword hashes a password using bcrypt with high cost
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
// Use cost of 12 for good security/performance balance
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword verifies a password against a hash
|
||||||
|
func CheckPassword(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new user account
|
||||||
|
func (s *Service) Register(email, password string) (*User, error) {
|
||||||
|
// Check if user exists
|
||||||
|
existing, _ := s.users.GetUserByEmail(email)
|
||||||
|
if existing != nil {
|
||||||
|
return nil, ErrUserExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if len(password) < 8 {
|
||||||
|
return nil, errors.New("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.users.CreateUser(email, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user and returns a JWT token
|
||||||
|
func (s *Service) Login(email, password string) (string, error) {
|
||||||
|
user, err := s.users.GetUserByEmail(email)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if !CheckPassword(password, user.Password) {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.generateToken(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken creates a new JWT token for a user
|
||||||
|
func (s *Service) generateToken(user *User) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(s.config.TokenDuration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
Issuer: "billit",
|
||||||
|
Subject: user.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(s.config.JWTSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns the claims
|
||||||
|
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return s.config.JWTSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
claims, err := s.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.users.GetUserByID(claims.UserID)
|
||||||
|
}
|
||||||
69
internal/auth/store.go
Normal file
69
internal/auth/store.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,15 +15,88 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "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.
|
// Service represents a service that interacts with a database.
|
||||||
type Service interface {
|
type Service interface {
|
||||||
// Health returns a map of health status information.
|
// Health returns a map of health status information.
|
||||||
// The keys and values in the map are service-specific.
|
|
||||||
Health() map[string]string
|
Health() map[string]string
|
||||||
|
|
||||||
// Close terminates the database connection.
|
// Close terminates the database connection.
|
||||||
// It returns an error if the connection cannot be closed.
|
|
||||||
Close() error
|
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 {
|
type service struct {
|
||||||
@@ -29,7 +104,7 @@ type service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
dburl = os.Getenv("BLUEPRINT_DB_URL")
|
dburl = os.Getenv("DB_PATH")
|
||||||
dbInstance *service
|
dbInstance *service
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,19 +114,400 @@ func New() Service {
|
|||||||
return dbInstance
|
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)
|
db, err := sql.Open("sqlite3", dburl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// This will not be a connection error, but a DSN parse error or
|
|
||||||
// another initialization error.
|
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbInstance = &service{
|
dbInstance = &service{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize tables
|
||||||
|
dbInstance.initTables()
|
||||||
|
|
||||||
return dbInstance
|
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.
|
// Health checks the health of the database connection by pinging the database.
|
||||||
// It returns a map with keys indicating various health statistics.
|
// It returns a map with keys indicating various health statistics.
|
||||||
func (s *service) Health() map[string]string {
|
func (s *service) Health() map[string]string {
|
||||||
|
|||||||
158
internal/gst/calculator.go
Normal file
158
internal/gst/calculator.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package gst
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rate represents standard GST rates
|
||||||
|
type Rate float64
|
||||||
|
|
||||||
|
const (
|
||||||
|
Rate0 Rate = 0.0
|
||||||
|
Rate5 Rate = 0.05
|
||||||
|
Rate12 Rate = 0.12
|
||||||
|
Rate18 Rate = 0.18
|
||||||
|
Rate28 Rate = 0.28
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerType distinguishes between B2B (Wholesale) and B2C (Retail)
|
||||||
|
type CustomerType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CustomerWholesale CustomerType = "wholesale"
|
||||||
|
CustomerRetail CustomerType = "retail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Product represents a catalog item
|
||||||
|
type Product struct {
|
||||||
|
SKU string
|
||||||
|
Name string
|
||||||
|
HSNCode string
|
||||||
|
BasePrice float64 // Price before tax
|
||||||
|
WholesalePrice float64 // Discounted price for B2B
|
||||||
|
GSTRate Rate
|
||||||
|
SmallOrderQty int // Minimum quantity threshold
|
||||||
|
SmallOrderFee float64 // Convenience fee when quantity is below threshold
|
||||||
|
Unit string // Unit of measurement (e.g., "pcs", "kg", "box")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LineItem represents a single row in the invoice
|
||||||
|
type LineItem struct {
|
||||||
|
Product Product
|
||||||
|
Quantity int
|
||||||
|
UnitPrice float64 // Actual price applied (wholesale vs retail)
|
||||||
|
TaxableVal float64 // Quantity * UnitPrice
|
||||||
|
CGSTAmount float64
|
||||||
|
SGSTAmount float64
|
||||||
|
IGSTAmount float64
|
||||||
|
TotalAmount float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice represents the full bill
|
||||||
|
type Invoice struct {
|
||||||
|
LineItems []LineItem
|
||||||
|
SubTotal float64
|
||||||
|
TotalCGST float64
|
||||||
|
TotalSGST float64
|
||||||
|
TotalIGST float64
|
||||||
|
ConvenienceFee float64 // Flat fee for small orders (before tax)
|
||||||
|
ConvenienceFeeTax float64 // GST on convenience fee (18% fixed)
|
||||||
|
GrandTotal float64
|
||||||
|
CustomerType CustomerType
|
||||||
|
IsInterState bool // True if selling to a different state (IGST applies)
|
||||||
|
CompanyDetails string // Multiline company details (displayed above invoice table)
|
||||||
|
BuyerDetails string // Multiline buyer details (displayed above bank details)
|
||||||
|
BuyerName string // Buyer's name
|
||||||
|
BankDetails string // Multiline bank details (displayed at bottom of invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculator handles the GST logic
|
||||||
|
type Calculator struct{}
|
||||||
|
|
||||||
|
// NewCalculator creates a new calculator instance
|
||||||
|
func NewCalculator() *Calculator {
|
||||||
|
return &Calculator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateLineItem computes taxes for a single line
|
||||||
|
func (c *Calculator) CalculateLineItem(p Product, qty int, custType CustomerType, isInterState bool) LineItem {
|
||||||
|
// Determine price based on customer type
|
||||||
|
price := p.BasePrice
|
||||||
|
if custType == CustomerWholesale {
|
||||||
|
price = p.WholesalePrice
|
||||||
|
}
|
||||||
|
|
||||||
|
taxableVal := price * float64(qty)
|
||||||
|
rate := float64(p.GSTRate)
|
||||||
|
|
||||||
|
var cgst, sgst, igst float64
|
||||||
|
|
||||||
|
if isInterState {
|
||||||
|
igst = taxableVal * rate
|
||||||
|
} else {
|
||||||
|
// Intra-state: Split tax between Center and State
|
||||||
|
halfRate := rate / 2
|
||||||
|
cgst = taxableVal * halfRate
|
||||||
|
sgst = taxableVal * halfRate
|
||||||
|
}
|
||||||
|
|
||||||
|
total := taxableVal + cgst + sgst + igst
|
||||||
|
|
||||||
|
return LineItem{
|
||||||
|
Product: p,
|
||||||
|
Quantity: qty,
|
||||||
|
UnitPrice: price,
|
||||||
|
TaxableVal: round(taxableVal),
|
||||||
|
CGSTAmount: round(cgst),
|
||||||
|
SGSTAmount: round(sgst),
|
||||||
|
IGSTAmount: round(igst),
|
||||||
|
TotalAmount: round(total),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateInvoice computes totals for the entire invoice
|
||||||
|
func (c *Calculator) CalculateInvoice(items []LineItem, fee float64, isInterState bool) Invoice {
|
||||||
|
inv := Invoice{
|
||||||
|
LineItems: items,
|
||||||
|
ConvenienceFee: fee,
|
||||||
|
IsInterState: isInterState,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
inv.SubTotal += item.TaxableVal
|
||||||
|
inv.TotalCGST += item.CGSTAmount
|
||||||
|
inv.TotalSGST += item.SGSTAmount
|
||||||
|
inv.TotalIGST += item.IGSTAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience fee is taxable at 18% fixed rate
|
||||||
|
if fee > 0 {
|
||||||
|
feeTax := fee * 0.18 // 18% GST on convenience fee
|
||||||
|
inv.ConvenienceFeeTax = round(feeTax)
|
||||||
|
// Add convenience fee to taxable subtotal
|
||||||
|
inv.SubTotal += fee
|
||||||
|
// Add convenience fee tax to appropriate tax fields
|
||||||
|
if isInterState {
|
||||||
|
inv.TotalIGST += inv.ConvenienceFeeTax
|
||||||
|
} else {
|
||||||
|
// Split between CGST and SGST (9% each)
|
||||||
|
inv.TotalCGST += round(feeTax / 2)
|
||||||
|
inv.TotalSGST += round(feeTax / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inv.GrandTotal = inv.SubTotal + inv.TotalCGST + inv.TotalSGST + inv.TotalIGST
|
||||||
|
|
||||||
|
// Rounding final totals
|
||||||
|
inv.SubTotal = round(inv.SubTotal)
|
||||||
|
inv.TotalCGST = round(inv.TotalCGST)
|
||||||
|
inv.TotalSGST = round(inv.TotalSGST)
|
||||||
|
inv.TotalIGST = round(inv.TotalIGST)
|
||||||
|
inv.GrandTotal = round(inv.GrandTotal)
|
||||||
|
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(num float64) float64 {
|
||||||
|
return math.Round(num*100) / 100
|
||||||
|
}
|
||||||
122
internal/gst/calculator_test.go
Normal file
122
internal/gst/calculator_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ package server
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"billit/cmd/web"
|
"billit/internal/api"
|
||||||
"github.com/a-h/templ"
|
"billit/internal/auth"
|
||||||
|
"billit/internal/web"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
)
|
)
|
||||||
@@ -22,27 +24,106 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Static files
|
||||||
fileServer := http.FileServer(http.FS(web.Files))
|
fileServer := http.FileServer(http.FS(web.Files))
|
||||||
e.GET("/assets/*", echo.WrapHandler(fileServer))
|
e.GET("/assets/*", echo.WrapHandler(fileServer))
|
||||||
|
|
||||||
e.GET("/web", echo.WrapHandler(templ.Handler(web.HelloForm())))
|
// ========================================
|
||||||
e.POST("/hello", echo.WrapHandler(http.HandlerFunc(web.HelloWebHandler)))
|
// Auth Setup
|
||||||
|
// ========================================
|
||||||
|
userStore := auth.NewDBUserStore(s.db)
|
||||||
|
authService := auth.NewService(userStore)
|
||||||
|
authHandlers := web.NewAuthHandlers(authService)
|
||||||
|
|
||||||
e.GET("/", s.HelloWorldHandler)
|
// ========================================
|
||||||
|
// API Routes (JSON responses) - Health only, products/invoice via web UI
|
||||||
|
// ========================================
|
||||||
|
apiHandlers := api.NewHandlers(s.db)
|
||||||
|
apiGroup := e.Group("/api")
|
||||||
|
{
|
||||||
|
apiGroup.GET("/health", apiHandlers.HealthHandler)
|
||||||
|
}
|
||||||
|
|
||||||
e.GET("/health", s.healthHandler)
|
// ========================================
|
||||||
|
// Public Web Routes (no auth required)
|
||||||
|
// ========================================
|
||||||
|
e.GET("/", authHandlers.LoginPageHandler)
|
||||||
|
e.POST("/login", authHandlers.LoginHandler)
|
||||||
|
e.GET("/register", authHandlers.RegisterPageHandler)
|
||||||
|
e.POST("/register", authHandlers.RegisterHandler)
|
||||||
|
e.GET("/logout", authHandlers.LogoutHandler)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Protected Web Routes (auth required)
|
||||||
|
// ========================================
|
||||||
|
protected := e.Group("")
|
||||||
|
protected.Use(authHandlers.AuthMiddleware)
|
||||||
|
|
||||||
|
// Home
|
||||||
|
homeHandlers := web.NewHomeHandlers(s.db)
|
||||||
|
protected.GET("/home", homeHandlers.HomePageHandler)
|
||||||
|
|
||||||
|
// Account routes
|
||||||
|
accountHandlers := web.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)
|
||||||
|
protected.GET("/buyer", buyerHandlers.BuyerListHandler)
|
||||||
|
protected.GET("/buyer/create", buyerHandlers.BuyerCreatePageHandler)
|
||||||
|
protected.POST("/buyer/create", buyerHandlers.BuyerCreateHandler)
|
||||||
|
protected.GET("/buyer/edit/:id", buyerHandlers.BuyerEditPageHandler)
|
||||||
|
protected.POST("/buyer/edit/:id", buyerHandlers.BuyerUpdateHandler)
|
||||||
|
protected.DELETE("/buyer/:id", buyerHandlers.BuyerDeleteHandler)
|
||||||
|
|
||||||
|
// Invoices list
|
||||||
|
invoicesHandlers := web.NewInvoicesHandlers(s.db)
|
||||||
|
protected.GET("/invoice", invoicesHandlers.InvoicesListHandler)
|
||||||
|
|
||||||
|
// Product routes (web UI)
|
||||||
|
productHandlers := web.NewProductHandlers(s.db)
|
||||||
|
protected.GET("/product", productHandlers.ProductListHandler)
|
||||||
|
protected.GET("/product/create", productHandlers.ProductCreatePageHandler)
|
||||||
|
protected.POST("/product/create", productHandlers.ProductCreateHandler)
|
||||||
|
protected.GET("/product/edit/:sku", productHandlers.ProductEditPageHandler)
|
||||||
|
protected.POST("/product/edit/:sku", productHandlers.ProductUpdateHandler)
|
||||||
|
protected.DELETE("/product/:sku", productHandlers.ProductDeleteHandler)
|
||||||
|
|
||||||
|
// Billing routes (web UI)
|
||||||
|
billingHandlers := web.NewBillingHandlers(s.db)
|
||||||
|
protected.GET("/billing", billingHandlers.BillingPageHandler)
|
||||||
|
protected.POST("/billing/calculate", billingHandlers.CalculateBillHandler)
|
||||||
|
protected.POST("/billing/generate", billingHandlers.GenerateBillHandler)
|
||||||
|
protected.GET("/billing/add-row", billingHandlers.AddProductRowHandler)
|
||||||
|
|
||||||
|
// Invoice view (protected - only owner can view)
|
||||||
|
protected.GET("/invoice/:id", billingHandlers.ShowInvoiceHandler)
|
||||||
|
|
||||||
|
// Legacy health check (kept for backward compatibility)
|
||||||
|
e.GET("/health", apiHandlers.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, "")
|
||||||
|
return
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
_ = web.RenderServerError(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default error handler for other cases
|
||||||
|
e.DefaultHTTPErrorHandler(err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch-all for undefined routes (must be last)
|
||||||
|
e.RouteNotFound("/*", func(c echo.Context) error {
|
||||||
|
return web.RenderNotFound(c, "")
|
||||||
|
})
|
||||||
|
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) HelloWorldHandler(c echo.Context) error {
|
|
||||||
resp := map[string]string{
|
|
||||||
"message": "Hello World",
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) healthHandler(c echo.Context) error {
|
|
||||||
return c.JSON(http.StatusOK, s.db.Health())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,39 +1,34 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"billit/internal/database"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHomeRoute(t *testing.T) {
|
||||||
e := echo.New()
|
// Create a minimal server with db for testing
|
||||||
|
db := database.New()
|
||||||
|
s := &Server{db: db}
|
||||||
|
handler := s.RegisterRoutes()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
c := e.NewContext(req, resp)
|
handler.ServeHTTP(resp, req)
|
||||||
s := &Server{}
|
|
||||||
// Assertions
|
|
||||||
if err := s.HelloWorldHandler(c); err != nil {
|
|
||||||
t.Errorf("handler() error = %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Errorf("handler() wrong status code = %v", resp.Code)
|
t.Errorf("home route wrong status code = %v, want %v", resp.Code, http.StatusOK)
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
expected := map[string]string{"message": "Hello World"}
|
|
||||||
var actual map[string]string
|
func TestRouterSetup(t *testing.T) {
|
||||||
// Decode the response body into the actual map
|
// Test that Echo router can be set up without panic
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&actual); err != nil {
|
e := echo.New()
|
||||||
t.Errorf("handler() error decoding response body: %v", err)
|
if e == nil {
|
||||||
return
|
t.Error("failed to create echo instance")
|
||||||
}
|
|
||||||
// Compare the decoded response with the expected value
|
|
||||||
if !reflect.DeepEqual(expected, actual) {
|
|
||||||
t.Errorf("handler() wrong response body. expected = %v, actual = %v", expected, actual)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
internal/web/account.go
Normal file
107
internal/web/account.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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", ""))
|
||||||
|
}
|
||||||
1
internal/web/assets/css/output.css
Normal file
1
internal/web/assets/css/output.css
Normal file
File diff suppressed because one or more lines are too long
1
internal/web/assets/css/output.css.map
Normal file
1
internal/web/assets/css/output.css.map
Normal file
File diff suppressed because one or more lines are too long
249
internal/web/assets/js/dialog.js
Normal file
249
internal/web/assets/js/dialog.js
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// 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 = `
|
||||||
|
<div class="dialog-box">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h3 class="dialog-title"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<p class="dialog-message"></p>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<button type="button" class="btn btn-outline dialog-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger dialog-confirm">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = '<p class="dialog-message">' + escapeHtml(message) + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="disclaimer-content">
|
||||||
|
<p style="font-weight: bold; margin-bottom: 15px;">
|
||||||
|
Please read these terms carefully before using this software. By proceeding, you agree to the conditions below:
|
||||||
|
</p>
|
||||||
|
<ul style="padding-left: 20px; line-height: 1.8; margin: 0;">
|
||||||
|
<li>
|
||||||
|
<strong>1. FREE OF CHARGE & CPA EXEMPTION:</strong> This software is provided strictly <strong>"Free of Charge"</strong> and without any monetary consideration. It therefore does not constitute a "Service" under the Indian Consumer Protection Act, 2019.
|
||||||
|
</li>
|
||||||
|
<li style="margin-top: 10px;">
|
||||||
|
<strong>2. "AS IS" & NO WARRANTY:</strong> The software is provided <strong>"AS IS"</strong>. The developer provides <strong>NO WARRANTY</strong>, express or implied, regarding its performance, accuracy, security, or suitability for any purpose.
|
||||||
|
</li>
|
||||||
|
<li style="margin-top: 10px;">
|
||||||
|
<strong>3. USER ASSUMPTION OF RISK:</strong> The developer is not liable for any financial losses, data corruption, calculation errors, or legal issues resulting from the use or misuse of this application. Users assume all associated risks and agree to indemnify and hold harmless the developer.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-size: 0.9em; font-style: italic; color: #666; margin-top: 15px; margin-bottom: 0;">
|
||||||
|
<small>Consult a qualified legal or financial advisor before relying on any data generated by this tool.</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
116
internal/web/assets/scss/_base.scss
Normal file
116
internal/web/assets/scss/_base.scss
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// ============================================
|
||||||
|
// BILLIT - Base/Reset Styles
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@use 'variables' as *;
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
line-height: $line-height-base;
|
||||||
|
color: $color-gray-900;
|
||||||
|
background-color: $color-white;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: $font-size-2xl; }
|
||||||
|
h2 { font-size: $font-size-xl; }
|
||||||
|
h3 { font-size: $font-size-lg; }
|
||||||
|
h4 { font-size: $font-size-md; }
|
||||||
|
h5 { font-size: $font-size-base; }
|
||||||
|
h6 { font-size: $font-size-sm; }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-accent;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
ul, ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
input, select, textarea, button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus styles
|
||||||
|
:focus {
|
||||||
|
outline: 2px solid $color-accent;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid $color-accent;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
::selection {
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar (webkit)
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: $color-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: $color-gray-400;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
1044
internal/web/assets/scss/_components.scss
Normal file
1044
internal/web/assets/scss/_components.scss
Normal file
File diff suppressed because it is too large
Load Diff
93
internal/web/assets/scss/_print.scss
Normal file
93
internal/web/assets/scss/_print.scss
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// ============================================
|
||||||
|
// BILLIT - Print Styles
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@use 'variables' as *;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
// Hide non-printable elements
|
||||||
|
.no-print,
|
||||||
|
.header,
|
||||||
|
.btn,
|
||||||
|
button {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset backgrounds
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
font-size: 11pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page setup
|
||||||
|
@page {
|
||||||
|
margin: 1cm;
|
||||||
|
size: A4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page breaks
|
||||||
|
.page-break {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avoid-break {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links
|
||||||
|
a {
|
||||||
|
color: black !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice specific
|
||||||
|
.invoice {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-header {
|
||||||
|
border-bottom: 2px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-table {
|
||||||
|
th {
|
||||||
|
background: #e0e0e0 !important;
|
||||||
|
color: black !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-totals {
|
||||||
|
.row-total {
|
||||||
|
background: #e0e0e0 !important;
|
||||||
|
color: black !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR code footer
|
||||||
|
.invoice-qr {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
197
internal/web/assets/scss/_utilities.scss
Normal file
197
internal/web/assets/scss/_utilities.scss
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// ============================================
|
||||||
|
// BILLIT - Utility Classes
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@use 'variables' as *;
|
||||||
|
|
||||||
|
// Display
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.block { display: block; }
|
||||||
|
.inline { display: inline; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.inline-flex { display: inline-flex; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
|
||||||
|
// Flex utilities
|
||||||
|
.flex-row { flex-direction: row; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.flex-grow { flex-grow: 1; }
|
||||||
|
.flex-shrink-0 { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.items-start { align-items: flex-start; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.items-end { align-items: flex-end; }
|
||||||
|
.items-stretch { align-items: stretch; }
|
||||||
|
|
||||||
|
.justify-start { justify-content: flex-start; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-end { justify-content: flex-end; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
|
||||||
|
.gap-1 { gap: $spacing-1; }
|
||||||
|
.gap-2 { gap: $spacing-2; }
|
||||||
|
.gap-3 { gap: $spacing-3; }
|
||||||
|
.gap-4 { gap: $spacing-4; }
|
||||||
|
.gap-6 { gap: $spacing-6; }
|
||||||
|
.gap-8 { gap: $spacing-8; }
|
||||||
|
|
||||||
|
// Text
|
||||||
|
.text-xs { font-size: $font-size-xs; }
|
||||||
|
.text-sm { font-size: $font-size-sm; }
|
||||||
|
.text-base { font-size: $font-size-base; }
|
||||||
|
.text-md { font-size: $font-size-md; }
|
||||||
|
.text-lg { font-size: $font-size-lg; }
|
||||||
|
.text-xl { font-size: $font-size-xl; }
|
||||||
|
.text-2xl { font-size: $font-size-2xl; }
|
||||||
|
.text-3xl { font-size: $font-size-3xl; }
|
||||||
|
|
||||||
|
.font-normal { font-weight: $font-weight-normal; }
|
||||||
|
.font-medium { font-weight: $font-weight-medium; }
|
||||||
|
.font-semibold { font-weight: $font-weight-semibold; }
|
||||||
|
.font-bold { font-weight: $font-weight-bold; }
|
||||||
|
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
.uppercase { text-transform: uppercase; }
|
||||||
|
.lowercase { text-transform: lowercase; }
|
||||||
|
.capitalize { text-transform: capitalize; }
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-nowrap { white-space: nowrap; }
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
.text-black { color: $color-black; }
|
||||||
|
.text-white { color: $color-white; }
|
||||||
|
.text-gray { color: $color-gray-600; }
|
||||||
|
.text-gray-dark { color: $color-gray-800; }
|
||||||
|
.text-gray-light { color: $color-gray-500; }
|
||||||
|
.text-primary { color: $color-primary; }
|
||||||
|
.text-accent { color: $color-accent; }
|
||||||
|
.text-success { color: $color-success; }
|
||||||
|
.text-warning { color: $color-warning; }
|
||||||
|
.text-error { color: $color-error; }
|
||||||
|
|
||||||
|
.bg-white { background-color: $color-white; }
|
||||||
|
.bg-gray-50 { background-color: $color-gray-50; }
|
||||||
|
.bg-gray-100 { background-color: $color-gray-100; }
|
||||||
|
.bg-gray-200 { background-color: $color-gray-200; }
|
||||||
|
.bg-black { background-color: $color-black; }
|
||||||
|
.bg-primary { background-color: $color-primary; }
|
||||||
|
|
||||||
|
// Spacing - Margin
|
||||||
|
.m-0 { margin: $spacing-0; }
|
||||||
|
.m-2 { margin: $spacing-2; }
|
||||||
|
.m-4 { margin: $spacing-4; }
|
||||||
|
.m-8 { margin: $spacing-8; }
|
||||||
|
|
||||||
|
.mt-0 { margin-top: $spacing-0; }
|
||||||
|
.mt-2 { margin-top: $spacing-2; }
|
||||||
|
.mt-4 { margin-top: $spacing-4; }
|
||||||
|
.mt-6 { margin-top: $spacing-6; }
|
||||||
|
.mt-8 { margin-top: $spacing-8; }
|
||||||
|
.mt-12 { margin-top: $spacing-12; }
|
||||||
|
|
||||||
|
.mb-0 { margin-bottom: $spacing-0; }
|
||||||
|
.mb-2 { margin-bottom: $spacing-2; }
|
||||||
|
.mb-4 { margin-bottom: $spacing-4; }
|
||||||
|
.mb-6 { margin-bottom: $spacing-6; }
|
||||||
|
.mb-8 { margin-bottom: $spacing-8; }
|
||||||
|
|
||||||
|
.ml-2 { margin-left: $spacing-2; }
|
||||||
|
.ml-4 { margin-left: $spacing-4; }
|
||||||
|
.mr-2 { margin-right: $spacing-2; }
|
||||||
|
.mr-4 { margin-right: $spacing-4; }
|
||||||
|
|
||||||
|
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||||
|
|
||||||
|
// Spacing - Padding
|
||||||
|
.p-0 { padding: $spacing-0; }
|
||||||
|
.p-2 { padding: $spacing-2; }
|
||||||
|
.p-3 { padding: $spacing-3; }
|
||||||
|
.p-4 { padding: $spacing-4; }
|
||||||
|
.p-6 { padding: $spacing-6; }
|
||||||
|
.p-8 { padding: $spacing-8; }
|
||||||
|
|
||||||
|
.px-2 { padding-left: $spacing-2; padding-right: $spacing-2; }
|
||||||
|
.px-4 { padding-left: $spacing-4; padding-right: $spacing-4; }
|
||||||
|
.px-6 { padding-left: $spacing-6; padding-right: $spacing-6; }
|
||||||
|
.px-8 { padding-left: $spacing-8; padding-right: $spacing-8; }
|
||||||
|
|
||||||
|
.py-2 { padding-top: $spacing-2; padding-bottom: $spacing-2; }
|
||||||
|
.py-3 { padding-top: $spacing-3; padding-bottom: $spacing-3; }
|
||||||
|
.py-4 { padding-top: $spacing-4; padding-bottom: $spacing-4; }
|
||||||
|
.py-6 { padding-top: $spacing-6; padding-bottom: $spacing-6; }
|
||||||
|
.py-8 { padding-top: $spacing-8; padding-bottom: $spacing-8; }
|
||||||
|
|
||||||
|
// Width/Height
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.w-auto { width: auto; }
|
||||||
|
.h-full { height: 100%; }
|
||||||
|
.h-screen { height: 100vh; }
|
||||||
|
.min-h-screen { min-height: 100vh; }
|
||||||
|
|
||||||
|
// Borders
|
||||||
|
.border { border: $border-width solid $border-color; }
|
||||||
|
.border-0 { border: none; }
|
||||||
|
.border-t { border-top: $border-width solid $border-color; }
|
||||||
|
.border-b { border-bottom: $border-width solid $border-color; }
|
||||||
|
.border-l { border-left: $border-width solid $border-color; }
|
||||||
|
.border-r { border-right: $border-width solid $border-color; }
|
||||||
|
.border-dark { border-color: $border-color-dark; }
|
||||||
|
.border-2 { border-width: $border-width-2; }
|
||||||
|
|
||||||
|
// Position
|
||||||
|
.relative { position: relative; }
|
||||||
|
.absolute { position: absolute; }
|
||||||
|
.fixed { position: fixed; }
|
||||||
|
.sticky { position: sticky; }
|
||||||
|
|
||||||
|
.top-0 { top: 0; }
|
||||||
|
.right-0 { right: 0; }
|
||||||
|
.bottom-0 { bottom: 0; }
|
||||||
|
.left-0 { left: 0; }
|
||||||
|
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
|
||||||
|
|
||||||
|
// Z-index
|
||||||
|
.z-10 { z-index: 10; }
|
||||||
|
.z-20 { z-index: 20; }
|
||||||
|
.z-50 { z-index: 50; }
|
||||||
|
.z-100 { z-index: 100; }
|
||||||
|
|
||||||
|
// Overflow
|
||||||
|
.overflow-hidden { overflow: hidden; }
|
||||||
|
.overflow-auto { overflow: auto; }
|
||||||
|
.overflow-x-auto { overflow-x: auto; }
|
||||||
|
.overflow-y-auto { overflow-y: auto; }
|
||||||
|
|
||||||
|
// Shadows
|
||||||
|
.shadow { box-shadow: $shadow-sm; }
|
||||||
|
.shadow-md { box-shadow: $shadow-md; }
|
||||||
|
.shadow-lg { box-shadow: $shadow-lg; }
|
||||||
|
.shadow-none { box-shadow: none; }
|
||||||
|
|
||||||
|
// Cursor
|
||||||
|
.cursor-pointer { cursor: pointer; }
|
||||||
|
.cursor-default { cursor: default; }
|
||||||
|
|
||||||
|
// Opacity
|
||||||
|
.opacity-50 { opacity: 0.5; }
|
||||||
|
.opacity-75 { opacity: 0.75; }
|
||||||
|
|
||||||
|
// Print utilities
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.print-only { display: block !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-only { display: none; }
|
||||||
116
internal/web/assets/scss/_variables.scss
Normal file
116
internal/web/assets/scss/_variables.scss
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// ============================================
|
||||||
|
// BILLIT - McMaster-Carr Inspired Design System
|
||||||
|
// Industrial, Dense, Functional, No Roundedness
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Colors - Industrial palette
|
||||||
|
$color-black: #000000;
|
||||||
|
$color-white: #ffffff;
|
||||||
|
$color-gray-50: #fafafa;
|
||||||
|
$color-gray-100: #f5f5f5;
|
||||||
|
$color-gray-200: #eeeeee;
|
||||||
|
$color-gray-300: #e0e0e0;
|
||||||
|
$color-gray-400: #bdbdbd;
|
||||||
|
$color-gray-500: #9e9e9e;
|
||||||
|
$color-gray-600: #757575;
|
||||||
|
$color-gray-700: #616161;
|
||||||
|
$color-gray-800: #424242;
|
||||||
|
$color-gray-900: #212121;
|
||||||
|
|
||||||
|
// Primary - Industrial orange (McMaster signature)
|
||||||
|
$color-primary: #e65100;
|
||||||
|
$color-primary-dark: #bf360c;
|
||||||
|
$color-primary-light: #ff6d00;
|
||||||
|
|
||||||
|
// Accent - Deep blue for links/actions
|
||||||
|
$color-accent: #0d47a1;
|
||||||
|
$color-accent-dark: #002171;
|
||||||
|
$color-accent-light: #1565c0;
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
$color-success: #2e7d32;
|
||||||
|
$color-warning: #f57c00;
|
||||||
|
$color-error: #c62828;
|
||||||
|
$color-info: #1565c0;
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
$font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
$font-family-mono: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", monospace;
|
||||||
|
|
||||||
|
$font-size-xs: 0.6875rem; // 11px
|
||||||
|
$font-size-sm: 0.75rem; // 12px
|
||||||
|
$font-size-base: 0.8125rem; // 13px
|
||||||
|
$font-size-md: 0.875rem; // 14px
|
||||||
|
$font-size-lg: 1rem; // 16px
|
||||||
|
$font-size-xl: 1.125rem; // 18px
|
||||||
|
$font-size-2xl: 1.25rem; // 20px
|
||||||
|
$font-size-3xl: 1.5rem; // 24px
|
||||||
|
|
||||||
|
$font-weight-normal: 400;
|
||||||
|
$font-weight-medium: 500;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
$font-weight-bold: 700;
|
||||||
|
|
||||||
|
$line-height-tight: 1.2;
|
||||||
|
$line-height-base: 1.4;
|
||||||
|
$line-height-loose: 1.6;
|
||||||
|
|
||||||
|
// Spacing - Dense, compact
|
||||||
|
$spacing-0: 0;
|
||||||
|
$spacing-1: 0.125rem; // 2px
|
||||||
|
$spacing-2: 0.25rem; // 4px
|
||||||
|
$spacing-3: 0.375rem; // 6px
|
||||||
|
$spacing-4: 0.5rem; // 8px
|
||||||
|
$spacing-5: 0.625rem; // 10px
|
||||||
|
$spacing-6: 0.75rem; // 12px
|
||||||
|
$spacing-8: 1rem; // 16px
|
||||||
|
$spacing-10: 1.25rem; // 20px
|
||||||
|
$spacing-12: 1.5rem; // 24px
|
||||||
|
$spacing-16: 2rem; // 32px
|
||||||
|
$spacing-20: 2.5rem; // 40px
|
||||||
|
|
||||||
|
// Borders - Sharp, no radius
|
||||||
|
$border-width: 1px;
|
||||||
|
$border-width-2: 2px;
|
||||||
|
$border-color: $color-gray-300;
|
||||||
|
$border-color-dark: $color-gray-400;
|
||||||
|
$border-radius: 0; // NO ROUNDEDNESS
|
||||||
|
|
||||||
|
// Shadows - Minimal
|
||||||
|
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
$transition-fast: 0.1s ease;
|
||||||
|
$transition-base: 0.15s ease;
|
||||||
|
$transition-slow: 0.25s ease;
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
$max-width-sm: 640px;
|
||||||
|
$max-width-md: 768px;
|
||||||
|
$max-width-lg: 1024px;
|
||||||
|
$max-width-xl: 1280px;
|
||||||
|
$max-width-2xl: 1536px;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
$header-height: 40px;
|
||||||
|
$header-bg: $color-black;
|
||||||
|
$header-text: $color-white;
|
||||||
|
|
||||||
|
// Table
|
||||||
|
$table-header-bg: $color-gray-100;
|
||||||
|
$table-border: $color-gray-300;
|
||||||
|
$table-row-hover: $color-gray-50;
|
||||||
|
$table-cell-padding: $spacing-3 $spacing-4;
|
||||||
|
|
||||||
|
// Form inputs
|
||||||
|
$input-height: 28px;
|
||||||
|
$input-padding: $spacing-2 $spacing-4;
|
||||||
|
$input-border: $border-color;
|
||||||
|
$input-focus-border: $color-accent;
|
||||||
|
$input-bg: $color-white;
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
$btn-height: 28px;
|
||||||
|
$btn-padding: $spacing-2 $spacing-6;
|
||||||
10
internal/web/assets/scss/main.scss
Normal file
10
internal/web/assets/scss/main.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// ============================================
|
||||||
|
// BILLIT - Main SCSS Entry Point
|
||||||
|
// McMaster-Carr Inspired Design System
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@use 'variables' as *;
|
||||||
|
@use 'base';
|
||||||
|
@use 'utilities';
|
||||||
|
@use 'components';
|
||||||
|
@use 'print';
|
||||||
138
internal/web/auth.go
Normal file
138
internal/web/auth.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"billit/internal/auth"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandlers holds auth service reference
|
||||||
|
type AuthHandlers struct {
|
||||||
|
auth *auth.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandlers creates handlers with auth service
|
||||||
|
func NewAuthHandlers(authService *auth.Service) *AuthHandlers {
|
||||||
|
return &AuthHandlers{auth: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginPageHandler renders the login page (home page)
|
||||||
|
func (h *AuthHandlers) LoginPageHandler(c echo.Context) error {
|
||||||
|
// Check if already logged in
|
||||||
|
cookie, err := c.Cookie("auth_token")
|
||||||
|
if err == nil && cookie.Value != "" {
|
||||||
|
_, err := h.auth.ValidateToken(cookie.Value)
|
||||||
|
if err == nil {
|
||||||
|
// Already logged in, redirect to home
|
||||||
|
return c.Redirect(http.StatusFound, "/home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Capture redirect URL from query param
|
||||||
|
redirectURL := c.QueryParam("redirect")
|
||||||
|
return Render(c, LoginPage("", "", redirectURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginHandler handles login form submission
|
||||||
|
func (h *AuthHandlers) LoginHandler(c echo.Context) error {
|
||||||
|
email := strings.TrimSpace(c.FormValue("email"))
|
||||||
|
password := c.FormValue("password")
|
||||||
|
redirectURL := c.FormValue("redirect")
|
||||||
|
|
||||||
|
if email == "" || password == "" {
|
||||||
|
return Render(c, 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HTTP-only cookie
|
||||||
|
cookie := h.auth.CreateAuthCookie(token)
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
|
// Redirect to original URL or home page
|
||||||
|
if redirectURL != "" && strings.HasPrefix(redirectURL, "/") {
|
||||||
|
return c.Redirect(http.StatusFound, redirectURL)
|
||||||
|
}
|
||||||
|
return c.Redirect(http.StatusFound, "/home")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterPageHandler renders the registration page
|
||||||
|
func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error {
|
||||||
|
return Render(c, RegisterPage("", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterHandler handles registration form submission
|
||||||
|
func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
|
||||||
|
email := strings.TrimSpace(c.FormValue("email"))
|
||||||
|
password := c.FormValue("password")
|
||||||
|
confirmPassword := c.FormValue("confirm_password")
|
||||||
|
|
||||||
|
if email == "" || password == "" {
|
||||||
|
return Render(c, RegisterPage("Email and password are required", email))
|
||||||
|
}
|
||||||
|
|
||||||
|
if password != confirmPassword {
|
||||||
|
return Render(c, RegisterPage("Passwords do not match", email))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 8 {
|
||||||
|
return Render(c, 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))
|
||||||
|
}
|
||||||
|
return Render(c, RegisterPage(err.Error(), email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-login after registration
|
||||||
|
token, err := h.auth.Login(email, password)
|
||||||
|
if err != nil {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := h.auth.CreateAuthCookie(token)
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
|
return c.Redirect(http.StatusFound, "/home")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutHandler clears the auth cookie and redirects to login
|
||||||
|
func (h *AuthHandlers) LogoutHandler(c echo.Context) error {
|
||||||
|
cookie := h.auth.ClearAuthCookie()
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware protects routes that require authentication
|
||||||
|
func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
cookie, err := c.Cookie("auth_token")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
// No cookie - redirect to login with original URL for post-login redirect
|
||||||
|
redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
|
||||||
|
return c.Redirect(http.StatusFound, "/?redirect="+redirectPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := h.auth.ValidateToken(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
// Invalid/expired token - show session expired dialog
|
||||||
|
c.SetCookie(h.auth.ClearAuthCookie())
|
||||||
|
redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
|
||||||
|
return Render(c, SessionExpiredPage(redirectPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user info in context
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
|
c.Set("user_email", claims.Email)
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
434
internal/web/billing.go
Normal file
434
internal/web/billing.go
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"billit/internal/database"
|
||||||
|
"billit/internal/gst"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// numberToWords converts a number to Indian English words (supports up to crores)
|
||||||
|
func numberToWords(n float64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "Zero"
|
||||||
|
}
|
||||||
|
|
||||||
|
ones := []string{"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
|
||||||
|
"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"}
|
||||||
|
tens := []string{"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"}
|
||||||
|
|
||||||
|
convertLessThanHundred := func(n int) string {
|
||||||
|
if n < 20 {
|
||||||
|
return ones[n]
|
||||||
|
}
|
||||||
|
if n%10 == 0 {
|
||||||
|
return tens[n/10]
|
||||||
|
}
|
||||||
|
return tens[n/10] + " " + ones[n%10]
|
||||||
|
}
|
||||||
|
|
||||||
|
convertLessThanThousand := func(n int) string {
|
||||||
|
if n < 100 {
|
||||||
|
return convertLessThanHundred(n)
|
||||||
|
}
|
||||||
|
if n%100 == 0 {
|
||||||
|
return ones[n/100] + " Hundred"
|
||||||
|
}
|
||||||
|
return ones[n/100] + " Hundred " + convertLessThanHundred(n%100)
|
||||||
|
}
|
||||||
|
// Split into rupees and paise
|
||||||
|
rupees := int(math.Floor(n))
|
||||||
|
paise := int(math.Round((n - float64(rupees)) * 100))
|
||||||
|
|
||||||
|
var result string
|
||||||
|
|
||||||
|
if rupees >= 10000000 { // Crores
|
||||||
|
crores := rupees / 10000000
|
||||||
|
rupees = rupees % 10000000
|
||||||
|
result += convertLessThanThousand(crores) + " Crore "
|
||||||
|
}
|
||||||
|
if rupees >= 100000 { // Lakhs
|
||||||
|
lakhs := rupees / 100000
|
||||||
|
rupees = rupees % 100000
|
||||||
|
result += convertLessThanHundred(lakhs) + " Lakh "
|
||||||
|
}
|
||||||
|
if rupees >= 1000 { // Thousands
|
||||||
|
thousands := rupees / 1000
|
||||||
|
rupees = rupees % 1000
|
||||||
|
result += convertLessThanHundred(thousands) + " Thousand "
|
||||||
|
}
|
||||||
|
if rupees > 0 {
|
||||||
|
result += convertLessThanThousand(rupees)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
if result == "" {
|
||||||
|
result = "Zero"
|
||||||
|
}
|
||||||
|
result += " Rupees"
|
||||||
|
|
||||||
|
if paise > 0 {
|
||||||
|
result += " and " + convertLessThanHundred(paise) + " Paise"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result + " Only"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BillingHandlers holds db reference for billing operations
|
||||||
|
type BillingHandlers struct {
|
||||||
|
db database.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBillingHandlers creates handlers with db access
|
||||||
|
func NewBillingHandlers(db database.Service) *BillingHandlers {
|
||||||
|
return &BillingHandlers{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BillingPageHandler renders the /billing page for creating bills
|
||||||
|
func (h *BillingHandlers) BillingPageHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
products, err := h.db.GetAllProducts(userID)
|
||||||
|
if err != nil {
|
||||||
|
products = []database.Product{}
|
||||||
|
}
|
||||||
|
buyers, err := h.db.GetAllBuyerDetails(userID)
|
||||||
|
if err != nil {
|
||||||
|
buyers = []database.BuyerDetails{}
|
||||||
|
}
|
||||||
|
return Render(c, BillingPage(products, buyers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateBillHandler calculates the bill (HTMX endpoint)
|
||||||
|
func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
customerType := c.FormValue("customer_type")
|
||||||
|
regionType := c.FormValue("region_type")
|
||||||
|
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
|
||||||
|
|
||||||
|
cType := gst.CustomerRetail
|
||||||
|
if customerType == "wholesale" {
|
||||||
|
cType = gst.CustomerWholesale
|
||||||
|
}
|
||||||
|
isInterState := regionType == "inter"
|
||||||
|
|
||||||
|
calculator := gst.NewCalculator()
|
||||||
|
var items []gst.LineItem
|
||||||
|
var totalFee float64
|
||||||
|
|
||||||
|
// Support up to 50 product slots for dynamic adding
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
sku := c.FormValue("product_sku_" + strconv.Itoa(i))
|
||||||
|
qtyStr := c.FormValue("qty_" + strconv.Itoa(i))
|
||||||
|
|
||||||
|
if sku == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
qty, err := strconv.Atoi(qtyStr)
|
||||||
|
if err != nil || qty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get product from DB (user-scoped)
|
||||||
|
dbProduct, err := h.db.GetProductBySKU(sku, userID)
|
||||||
|
if err != nil || dbProduct == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to gst.Product
|
||||||
|
product := gst.Product{
|
||||||
|
SKU: dbProduct.SKU,
|
||||||
|
Name: dbProduct.Name,
|
||||||
|
HSNCode: dbProduct.HSNCode,
|
||||||
|
BasePrice: dbProduct.BasePrice,
|
||||||
|
WholesalePrice: dbProduct.WholesalePrice,
|
||||||
|
GSTRate: gst.Rate(dbProduct.GSTRate),
|
||||||
|
SmallOrderQty: dbProduct.SmallOrderQty,
|
||||||
|
SmallOrderFee: dbProduct.SmallOrderFee,
|
||||||
|
Unit: dbProduct.Unit,
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState)
|
||||||
|
items = append(items, lineItem)
|
||||||
|
|
||||||
|
// Apply per-product convenience fee if quantity is below threshold and checkbox is checked
|
||||||
|
if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 {
|
||||||
|
totalFee += product.SmallOrderFee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice := calculator.CalculateInvoice(items, totalFee, isInterState)
|
||||||
|
invoice.CustomerType = cType
|
||||||
|
|
||||||
|
return Render(c, InvoiceSummary(invoice))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBillHandler generates final invoice with UUID and persists to DB
|
||||||
|
func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
customerType := c.FormValue("customer_type")
|
||||||
|
regionType := c.FormValue("region_type")
|
||||||
|
buyerID := c.FormValue("buyer_id")
|
||||||
|
includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes"
|
||||||
|
|
||||||
|
cType := gst.CustomerRetail
|
||||||
|
if customerType == "wholesale" {
|
||||||
|
cType = gst.CustomerWholesale
|
||||||
|
}
|
||||||
|
isInterState := regionType == "inter"
|
||||||
|
|
||||||
|
calculator := gst.NewCalculator()
|
||||||
|
var items []gst.LineItem
|
||||||
|
var totalFee float64
|
||||||
|
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
sku := c.FormValue("product_sku_" + strconv.Itoa(i))
|
||||||
|
qtyStr := c.FormValue("qty_" + strconv.Itoa(i))
|
||||||
|
|
||||||
|
if sku == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
qty, err := strconv.Atoi(qtyStr)
|
||||||
|
if err != nil || qty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dbProduct, err := h.db.GetProductBySKU(sku, userID)
|
||||||
|
if err != nil || dbProduct == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
product := gst.Product{
|
||||||
|
SKU: dbProduct.SKU,
|
||||||
|
Name: dbProduct.Name,
|
||||||
|
HSNCode: dbProduct.HSNCode,
|
||||||
|
BasePrice: dbProduct.BasePrice,
|
||||||
|
WholesalePrice: dbProduct.WholesalePrice,
|
||||||
|
GSTRate: gst.Rate(dbProduct.GSTRate),
|
||||||
|
SmallOrderQty: dbProduct.SmallOrderQty,
|
||||||
|
SmallOrderFee: dbProduct.SmallOrderFee,
|
||||||
|
Unit: dbProduct.Unit,
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState)
|
||||||
|
items = append(items, lineItem)
|
||||||
|
|
||||||
|
// Apply per-product convenience fee if checkbox is checked
|
||||||
|
if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 {
|
||||||
|
totalFee += product.SmallOrderFee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return c.String(http.StatusBadRequest, "No products selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice := calculator.CalculateInvoice(items, totalFee, isInterState)
|
||||||
|
invoice.CustomerType = cType
|
||||||
|
|
||||||
|
// Get user's company and bank details
|
||||||
|
user, err := h.db.GetUserByID(userID)
|
||||||
|
if err == nil && user != nil {
|
||||||
|
invoice.CompanyDetails = user.CompanyDetails
|
||||||
|
invoice.BankDetails = user.BankDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get buyer details if selected
|
||||||
|
if buyerID != "" {
|
||||||
|
buyer, err := h.db.GetBuyerDetails(buyerID, userID)
|
||||||
|
if err == nil && buyer != nil {
|
||||||
|
invoice.BuyerName = buyer.Name
|
||||||
|
invoice.BuyerDetails = buyer.Details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate UUID for invoice
|
||||||
|
invoiceID := uuid.New().String()
|
||||||
|
|
||||||
|
// Generate human-readable invoice ID
|
||||||
|
humanReadableID, err := h.db.GetNextInvoiceNumber(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "failed to generate invoice number")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to DB (user-scoped)
|
||||||
|
if err := h.db.CreateInvoice(invoiceID, humanReadableID, invoice, userID); err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "failed to save invoice")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to invoice view
|
||||||
|
c.Response().Header().Set("HX-Redirect", fmt.Sprintf("/invoice/%s", invoiceID))
|
||||||
|
return c.NoContent(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowInvoiceHandler displays the invoice by UUID (requires auth)
|
||||||
|
func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
invoiceID := c.Param("id")
|
||||||
|
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON data back into Invoice struct
|
||||||
|
var invoice gst.Invoice
|
||||||
|
if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "failed to parse invoice data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code for invoice URL
|
||||||
|
invoiceURL := fmt.Sprintf("%s://%s/invoice/%s", c.Scheme(), c.Request().Host, invoiceID)
|
||||||
|
qrPNG, err := qrcode.Encode(invoiceURL, qrcode.Medium, 100)
|
||||||
|
if err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "failed to generate QR code")
|
||||||
|
}
|
||||||
|
qrBase64 := base64.StdEncoding.EncodeToString(qrPNG)
|
||||||
|
|
||||||
|
// Render printable invoice page with multi-page support
|
||||||
|
c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
|
||||||
|
w := c.Response().Writer
|
||||||
|
fmt.Fprint(w, "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Invoice ")
|
||||||
|
fmt.Fprintf(w, "%s</title>", invoiceID[:8])
|
||||||
|
fmt.Fprint(w, "<link href='/assets/css/output.css' rel='stylesheet'>")
|
||||||
|
fmt.Fprint(w, `<style type="text/css">
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.page-break { page-break-before: always; }
|
||||||
|
@page { margin: 1cm; size: A4; }
|
||||||
|
body { background: white !important; }
|
||||||
|
.invoice-table th { background: #e0e0e0 !important; color: black !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.invoice-totals .row-total { background: #e0e0e0 !important; color: black !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
}
|
||||||
|
.invoice-details-block { white-space: pre-wrap; font-size: 0.875rem; line-height: 1.4; }
|
||||||
|
.invoice-footer-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; }
|
||||||
|
</style>`)
|
||||||
|
fmt.Fprint(w, "</head><body>")
|
||||||
|
fmt.Fprintf(w, `<div class="container page">
|
||||||
|
<div class="no-print" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;padding-bottom:1rem;border-bottom:1px solid #e0e0e0;">
|
||||||
|
<a href="/invoice" class="text-accent">← Back</a>
|
||||||
|
<button onclick="window.print()" class="btn btn-primary">Print Invoice</button>
|
||||||
|
</div>
|
||||||
|
<div class="invoice">
|
||||||
|
<div class="invoice-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="invoice-title">Tax Invoice</h1>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-meta">
|
||||||
|
<p>Invoice ID: %s</p>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-meta">
|
||||||
|
<p>Date: %s</p>
|
||||||
|
</div>
|
||||||
|
</div>`, inv.HumanReadableID, strings.ReplaceAll(inv.CreatedAt, "T", " ")[0:10])
|
||||||
|
|
||||||
|
// Display company details above the invoice table
|
||||||
|
if invoice.CompanyDetails != "" {
|
||||||
|
fmt.Fprintf(w, `<div class="invoice-details-block" style="margin-bottom:1rem;"><strong>From:</strong><br>%s</div>`, invoice.CompanyDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display buyer details and total amount in same section (50-50 split or 100% if no buyer)
|
||||||
|
totalGST := invoice.TotalCGST + invoice.TotalSGST + invoice.TotalIGST
|
||||||
|
hasBuyerInfo := invoice.BuyerName != "" || invoice.BuyerDetails != ""
|
||||||
|
|
||||||
|
if hasBuyerInfo {
|
||||||
|
fmt.Fprint(w, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;gap:2rem;">`)
|
||||||
|
// Left side: Billed To (50%)
|
||||||
|
fmt.Fprint(w, `<div style="flex:1;"><strong>Billed To:</strong><br>`)
|
||||||
|
if invoice.BuyerName != "" {
|
||||||
|
fmt.Fprintf(w, `<span class="invoice-details-block">%s</span>`, invoice.BuyerName)
|
||||||
|
}
|
||||||
|
if invoice.BuyerDetails != "" {
|
||||||
|
fmt.Fprintf(w, `<br><span class="invoice-details-block">%s</span>`, invoice.BuyerDetails)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `</div>`)
|
||||||
|
// Right side: Total Amount (50%)
|
||||||
|
fmt.Fprintf(w, `<div style="flex:1;">
|
||||||
|
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
|
||||||
|
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
|
||||||
|
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, `<div class="invoice-footer-section">`)
|
||||||
|
// Total Amount takes 100%
|
||||||
|
fmt.Fprintf(w, `<div>
|
||||||
|
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
|
||||||
|
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
|
||||||
|
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, `</div>`)
|
||||||
|
|
||||||
|
// Bank details (left) and QR code (right) in the same section
|
||||||
|
fmt.Fprint(w, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;align-items:flex-start;">`)
|
||||||
|
if invoice.BankDetails != "" {
|
||||||
|
fmt.Fprintf(w, `<div style="flex:1;"><strong>Bank Details:</strong><br><span class="invoice-details-block">%s</span></div>`, invoice.BankDetails)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, `<div style="flex:1;"></div>`)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<div style="margin-left:1rem;"><img src="data:image/png;base64,%s" alt="QR Code" style="width:80px;height:80px;"></div>`, qrBase64)
|
||||||
|
fmt.Fprint(w, `</div>`)
|
||||||
|
|
||||||
|
fmt.Fprint(w, "</div>")
|
||||||
|
|
||||||
|
fmt.Fprint(w, "</div></body></html>")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProductRowHandler returns HTML for a new product row (HTMX endpoint)
|
||||||
|
func (h *BillingHandlers) AddProductRowHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
indexStr := c.QueryParam("index")
|
||||||
|
index, err := strconv.Atoi(indexStr)
|
||||||
|
if err != nil {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
products, _ := h.db.GetAllProducts(userID)
|
||||||
|
|
||||||
|
// Build product options HTML
|
||||||
|
productOptions := `<option value="">-- Select Product --</option>`
|
||||||
|
for _, p := range products {
|
||||||
|
productOptions += fmt.Sprintf(`<option value="%s">%s (₹%.2f)</option>`, p.SKU, p.Name, p.BasePrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowHTML := fmt.Sprintf(`
|
||||||
|
<div class="product-row">
|
||||||
|
<div class="product-row-grid">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label class="form-label">Product</label>
|
||||||
|
<select name="product_sku_%d" class="form-select">%s</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label class="form-label">Qty</label>
|
||||||
|
<input type="number" name="qty_%d" value="1" min="0" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div style="padding-top:20px;">
|
||||||
|
<button type="button" onclick="this.closest('.product-row').remove()" class="btn btn-danger btn-sm">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`, index, productOptions, index)
|
||||||
|
|
||||||
|
return c.HTML(http.StatusOK, rowHTML)
|
||||||
|
}
|
||||||
106
internal/web/buyer.go
Normal file
106
internal/web/buyer.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"billit/internal/database"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuyerHandlers holds db reference for buyer operations
|
||||||
|
type BuyerHandlers struct {
|
||||||
|
db database.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuyerHandlers creates handlers with db access
|
||||||
|
func NewBuyerHandlers(db database.Service) *BuyerHandlers {
|
||||||
|
return &BuyerHandlers{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyerListHandler renders the /buyer page with all buyers
|
||||||
|
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 Render(c, BuyerListPage(buyers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyerCreatePageHandler renders the /buyer/create form page
|
||||||
|
func (h *BuyerHandlers) BuyerCreatePageHandler(c echo.Context) error {
|
||||||
|
return Render(c, BuyerCreatePage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyerEditPageHandler renders the /buyer/edit/:id form page
|
||||||
|
func (h *BuyerHandlers) BuyerEditPageHandler(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
|
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 Render(c, BuyerEditPage(*buyer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyerCreateHandler handles POST /buyer/create
|
||||||
|
func (h *BuyerHandlers) BuyerCreateHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
return c.String(http.StatusBadRequest, "Name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
details := c.FormValue("details")
|
||||||
|
|
||||||
|
_, err := h.db.CreateBuyerDetails(userID, name, details)
|
||||||
|
if err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "Failed to create buyer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(http.StatusFound, "/buyer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyerUpdateHandler handles POST /buyer/edit/:id
|
||||||
|
func (h *BuyerHandlers) BuyerUpdateHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.Param("id")
|
||||||
|
name := c.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
return c.String(http.StatusBadRequest, "Name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
details := c.FormValue("details")
|
||||||
|
|
||||||
|
err := h.db.UpdateBuyerDetails(id, userID, name, details)
|
||||||
|
if err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "Failed to update buyer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(http.StatusFound, "/buyer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyerDeleteHandler handles DELETE /buyer/:id
|
||||||
|
func (h *BuyerHandlers) BuyerDeleteHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.String(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.Param("id")
|
||||||
|
err := h.db.DeleteBuyerDetails(id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "Failed to delete buyer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusOK)
|
||||||
|
}
|
||||||
37
internal/web/home.go
Normal file
37
internal/web/home.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"billit/internal/database"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HomeHandlers holds db reference for home page operations
|
||||||
|
type HomeHandlers struct {
|
||||||
|
db database.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHomeHandlers creates handlers with db access
|
||||||
|
func NewHomeHandlers(db database.Service) *HomeHandlers {
|
||||||
|
return &HomeHandlers{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomePageHandler renders the home page with recent data
|
||||||
|
func (h *HomeHandlers) HomePageHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
userEmail, _ := c.Get("user_email").(string)
|
||||||
|
|
||||||
|
// Get recent products (last 5)
|
||||||
|
recentProducts, err := h.db.GetRecentProducts(userID, 5)
|
||||||
|
if err != nil {
|
||||||
|
recentProducts = []database.Product{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent invoices (last 5)
|
||||||
|
recentInvoices, err := h.db.GetRecentInvoices(userID, 5)
|
||||||
|
if err != nil {
|
||||||
|
recentInvoices = []database.Invoice{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Render(c, HomePage(userEmail, recentProducts, recentInvoices))
|
||||||
|
}
|
||||||
32
internal/web/invoices.go
Normal file
32
internal/web/invoices.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"billit/internal/database"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InvoicesHandlers holds db reference for invoice operations
|
||||||
|
type InvoicesHandlers struct {
|
||||||
|
db database.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInvoicesHandlers creates handlers with db access
|
||||||
|
func NewInvoicesHandlers(db database.Service) *InvoicesHandlers {
|
||||||
|
return &InvoicesHandlers{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoicesListHandler renders the /invoice page with all invoices
|
||||||
|
func (h *InvoicesHandlers) InvoicesListHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
invoices, err := h.db.GetAllInvoices(userID)
|
||||||
|
if err != nil {
|
||||||
|
return RenderServerError(c, "Failed to load invoices. Please try again.")
|
||||||
|
}
|
||||||
|
return Render(c, InvoicesPage(invoices))
|
||||||
|
}
|
||||||
231
internal/web/product.go
Normal file
231
internal/web/product.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"billit/internal/database"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProductHandlers holds db reference for product operations
|
||||||
|
type ProductHandlers struct {
|
||||||
|
db database.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProductHandlers creates handlers with db access
|
||||||
|
func NewProductHandlers(db database.Service) *ProductHandlers {
|
||||||
|
return &ProductHandlers{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserID extracts user ID from context (set by auth middleware)
|
||||||
|
func getUserID(c echo.Context) string {
|
||||||
|
if uid, ok := c.Get("user_id").(string); ok {
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductListHandler renders the /product page with all products
|
||||||
|
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 Render(c, ProductListPage(products))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductCreatePageHandler renders the /product/create form page
|
||||||
|
func (h *ProductHandlers) ProductCreatePageHandler(c echo.Context) error {
|
||||||
|
return Render(c, ProductCreatePage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductEditPageHandler renders the /product/edit/:sku form page
|
||||||
|
func (h *ProductHandlers) ProductEditPageHandler(c echo.Context) error {
|
||||||
|
sku := c.Param("sku")
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
|
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 Render(c, ProductEditPage(*product))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductCreateHandler handles POST /product/create
|
||||||
|
func (h *ProductHandlers) ProductCreateHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
sku := c.FormValue("sku")
|
||||||
|
if sku == "" {
|
||||||
|
return c.String(http.StatusBadRequest, "SKU is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
return c.String(http.StatusBadRequest, "Name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SKU already exists for this user
|
||||||
|
existing, _ := h.db.GetProductBySKU(sku, userID)
|
||||||
|
if existing != nil {
|
||||||
|
return c.String(http.StatusBadRequest, "A product with this SKU already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
hsn := c.FormValue("hsn")
|
||||||
|
baseStr := c.FormValue("base_price")
|
||||||
|
wholesaleStr := c.FormValue("wholesale_price")
|
||||||
|
gstStr := c.FormValue("gst_rate")
|
||||||
|
smallQtyStr := c.FormValue("small_order_qty")
|
||||||
|
|
||||||
|
base, _ := strconv.ParseFloat(baseStr, 64)
|
||||||
|
wholesale, _ := strconv.ParseFloat(wholesaleStr, 64)
|
||||||
|
if wholesale == 0 {
|
||||||
|
wholesale = base // default wholesale to base price
|
||||||
|
}
|
||||||
|
|
||||||
|
gstRate := 0.18 // default 18%
|
||||||
|
switch gstStr {
|
||||||
|
case "0":
|
||||||
|
gstRate = 0.0
|
||||||
|
case "5":
|
||||||
|
gstRate = 0.05
|
||||||
|
case "12":
|
||||||
|
gstRate = 0.12
|
||||||
|
case "18":
|
||||||
|
gstRate = 0.18
|
||||||
|
case "28":
|
||||||
|
gstRate = 0.28
|
||||||
|
}
|
||||||
|
|
||||||
|
smallQty := 1
|
||||||
|
if v, err := strconv.Atoi(smallQtyStr); err == nil && v > 0 {
|
||||||
|
smallQty = v
|
||||||
|
}
|
||||||
|
|
||||||
|
smallFeeStr := c.FormValue("small_order_fee")
|
||||||
|
smallFee, _ := strconv.ParseFloat(smallFeeStr, 64)
|
||||||
|
|
||||||
|
unit := c.FormValue("unit")
|
||||||
|
if unit == "" {
|
||||||
|
unit = "pcs"
|
||||||
|
}
|
||||||
|
|
||||||
|
product := database.Product{
|
||||||
|
SKU: sku,
|
||||||
|
Name: name,
|
||||||
|
HSNCode: hsn,
|
||||||
|
BasePrice: base,
|
||||||
|
WholesalePrice: wholesale,
|
||||||
|
GSTRate: gstRate,
|
||||||
|
SmallOrderQty: smallQty,
|
||||||
|
SmallOrderFee: smallFee,
|
||||||
|
Unit: unit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.CreateProduct(product, userID); err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "failed to create product")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to product list
|
||||||
|
return c.Redirect(http.StatusSeeOther, "/product")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductUpdateHandler handles POST /product/edit/:sku
|
||||||
|
func (h *ProductHandlers) ProductUpdateHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
sku := c.Param("sku")
|
||||||
|
|
||||||
|
// Verify product belongs to user
|
||||||
|
existing, _ := h.db.GetProductBySKU(sku, userID)
|
||||||
|
if existing == nil {
|
||||||
|
return c.String(http.StatusNotFound, "Product not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
return c.String(http.StatusBadRequest, "Name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
hsn := c.FormValue("hsn")
|
||||||
|
baseStr := c.FormValue("base_price")
|
||||||
|
wholesaleStr := c.FormValue("wholesale_price")
|
||||||
|
gstStr := c.FormValue("gst_rate")
|
||||||
|
smallQtyStr := c.FormValue("small_order_qty")
|
||||||
|
|
||||||
|
base, _ := strconv.ParseFloat(baseStr, 64)
|
||||||
|
wholesale, _ := strconv.ParseFloat(wholesaleStr, 64)
|
||||||
|
if wholesale == 0 {
|
||||||
|
wholesale = base
|
||||||
|
}
|
||||||
|
|
||||||
|
gstRate := 0.18
|
||||||
|
switch gstStr {
|
||||||
|
case "0":
|
||||||
|
gstRate = 0.0
|
||||||
|
case "5":
|
||||||
|
gstRate = 0.05
|
||||||
|
case "12":
|
||||||
|
gstRate = 0.12
|
||||||
|
case "18":
|
||||||
|
gstRate = 0.18
|
||||||
|
case "28":
|
||||||
|
gstRate = 0.28
|
||||||
|
}
|
||||||
|
|
||||||
|
smallQty := 1
|
||||||
|
if v, err := strconv.Atoi(smallQtyStr); err == nil && v > 0 {
|
||||||
|
smallQty = v
|
||||||
|
}
|
||||||
|
|
||||||
|
smallFeeStr := c.FormValue("small_order_fee")
|
||||||
|
smallFee, _ := strconv.ParseFloat(smallFeeStr, 64)
|
||||||
|
|
||||||
|
unit := c.FormValue("unit")
|
||||||
|
if unit == "" {
|
||||||
|
unit = "pcs"
|
||||||
|
}
|
||||||
|
|
||||||
|
product := database.Product{
|
||||||
|
SKU: sku,
|
||||||
|
Name: name,
|
||||||
|
HSNCode: hsn,
|
||||||
|
BasePrice: base,
|
||||||
|
WholesalePrice: wholesale,
|
||||||
|
GSTRate: gstRate,
|
||||||
|
SmallOrderQty: smallQty,
|
||||||
|
SmallOrderFee: smallFee,
|
||||||
|
Unit: unit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.UpdateProduct(product, userID); err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "failed to update product")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(http.StatusSeeOther, "/product")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductDeleteHandler handles DELETE /product/:sku
|
||||||
|
func (h *ProductHandlers) ProductDeleteHandler(c echo.Context) error {
|
||||||
|
userID := getUserID(c)
|
||||||
|
sku := c.Param("sku")
|
||||||
|
|
||||||
|
if err := h.db.DeleteProduct(sku, userID); err != nil {
|
||||||
|
return c.String(http.StatusInternalServerError, "failed to delete product")
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTMX, return empty to remove the row
|
||||||
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
|
return c.NoContent(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(http.StatusSeeOther, "/product")
|
||||||
|
}
|
||||||
12
internal/web/render.go
Normal file
12
internal/web/render.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render wraps templ component rendering for Echo
|
||||||
|
func Render(c echo.Context, component templ.Component) error {
|
||||||
|
c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
|
||||||
|
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
content: ["./**/*.html", "./**/*.templ", "./**/*.go",],
|
|
||||||
theme: { extend: {}, },
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user