commit d8cb1d277f665d82bcefbb5c67ede19a70a31e10 Author: Blake Ridgway Date: Fri Mar 27 23:18:17 2026 -0500 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..175090b --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +PORT=8082 +DATABASE_PATH=./arcline-billing.db + +SMTP_HOST=mail.spacemail.com +SMTP_PORT=587 +SMTP_USER=blake@arclineit.com +SMTP_PASS= +SMTP_FROM=support@arclineit.com + +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_SHARED_STARTER=price_... +STRIPE_PRICE_SHARED_PRO=price_... +STRIPE_PRICE_SHARED_BUSINESS=price_... +STRIPE_PRICE_WP_STARTER=price_... +STRIPE_PRICE_WP_PRO=price_... +STRIPE_PRICE_WP_BUSINESS=price_... +STRIPE_PRICE_VPS_1=price_... +STRIPE_PRICE_VPS_2=price_... +STRIPE_PRICE_VPS_3=price_... +STRIPE_PRICE_VPS_4=price_... + +BASE_URL=https://client.arclineit.com +SESSION_SECURE=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d24a68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Go build output +arcline-billing +*.test +*.out + +# Environment — never commit real credentials +.env + +# SQLite database +*.db +*.db-shm +*.db-wal diff --git a/billing b/billing new file mode 100755 index 0000000..6e47bda Binary files /dev/null and b/billing differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f6e8bca --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module arclineit.com/billing + +go 1.22 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/stripe/stripe-go/v81 v81.4.0 + golang.org/x/crypto v0.28.0 + golang.org/x/time v0.7.0 + modernc.org/sqlite v1.33.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.26.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c72a769 --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= +github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8447642 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,194 @@ +package auth + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + bcryptCost = 12 + SessionTTL = 30 * 24 * time.Hour + resetTokenTTL = 1 * time.Hour +) + +type contextKey int + +const contextKeyCustomerID contextKey = iota + +// HashPassword hashes plain with bcrypt cost 12. +func HashPassword(plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + return string(b), nil +} + +// CheckPassword returns true when plain matches the stored bcrypt hash. +func CheckPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} + +// CreateSession inserts a new session row and returns the random token. +func CreateSession(db *sql.DB, customerID int64) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("rand token: %w", err) + } + token := hex.EncodeToString(raw) + expiresAt := time.Now().UTC().Add(SessionTTL).Format(time.RFC3339) + + _, err := db.Exec( + `INSERT INTO sessions (token, customer_id, expires_at) VALUES (?, ?, ?)`, + token, customerID, expiresAt, + ) + if err != nil { + return "", fmt.Errorf("insert session: %w", err) + } + return token, nil +} + +// GetSession looks up a session token and returns the associated customer ID. +func GetSession(db *sql.DB, token string) (int64, error) { + var customerID int64 + var expiresAt string + + err := db.QueryRow( + `SELECT customer_id, expires_at FROM sessions WHERE token = ?`, + token, + ).Scan(&customerID, &expiresAt) + + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("session not found") + } + if err != nil { + return 0, fmt.Errorf("query session: %w", err) + } + + exp, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return 0, fmt.Errorf("parse expires_at: %w", err) + } + + if time.Now().UTC().After(exp) { + _, _ = db.Exec(`DELETE FROM sessions WHERE token = ?`, token) + return 0, fmt.Errorf("session expired") + } + + return customerID, nil +} + +// DeleteSession removes a session from the database. +func DeleteSession(db *sql.DB, token string) error { + _, err := db.Exec(`DELETE FROM sessions WHERE token = ?`, token) + if err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +// CreateResetToken inserts a new password_resets row and returns the token. +func CreateResetToken(db *sql.DB, customerID int64) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("rand token: %w", err) + } + token := hex.EncodeToString(raw) + expiresAt := time.Now().UTC().Add(resetTokenTTL).Format(time.RFC3339) + + _, err := db.Exec( + `INSERT INTO password_resets (token, customer_id, expires_at) VALUES (?, ?, ?)`, + token, customerID, expiresAt, + ) + if err != nil { + return "", fmt.Errorf("insert reset token: %w", err) + } + return token, nil +} + +// ValidateResetToken checks the token is valid and unused, returns customer ID. +func ValidateResetToken(db *sql.DB, token string) (int64, error) { + var customerID int64 + var expiresAt string + var used int + + err := db.QueryRow( + `SELECT customer_id, expires_at, used FROM password_resets WHERE token = ?`, + token, + ).Scan(&customerID, &expiresAt, &used) + + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("reset token not found") + } + if err != nil { + return 0, fmt.Errorf("query reset token: %w", err) + } + + if used != 0 { + return 0, fmt.Errorf("reset token already used") + } + + exp, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return 0, fmt.Errorf("parse expires_at: %w", err) + } + + if time.Now().UTC().After(exp) { + return 0, fmt.Errorf("reset token expired") + } + + return customerID, nil +} + +// ConsumeResetToken marks the token as used. +func ConsumeResetToken(db *sql.DB, token string) error { + _, err := db.Exec(`UPDATE password_resets SET used = 1 WHERE token = ?`, token) + if err != nil { + return fmt.Errorf("consume reset token: %w", err) + } + return nil +} + +// Middleware returns HTTP middleware that validates the session cookie and +// injects the customer ID into the request context. +func Middleware(db *sql.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + customerID, err := GetSession(db, cookie.Value) + if err != nil { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + ctx := context.WithValue(r.Context(), contextKeyCustomerID, customerID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// CustomerIDFromContext extracts the customer ID stored by Middleware. +func CustomerIDFromContext(ctx context.Context) int64 { + v, _ := ctx.Value(contextKeyCustomerID).(int64) + return v +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..12ddac5 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,88 @@ +package db + +import ( + "database/sql" + "fmt" + "log/slog" + + _ "modernc.org/sqlite" +) + +// Open opens (or creates) the SQLite database and runs schema migrations. +func Open(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping db: %w", err) + } + + if err := runSchema(db); err != nil { + return nil, fmt.Errorf("schema: %w", err) + } + + slog.Info("database ready", "path", path) + return db, nil +} + +func runSchema(db *sql.DB) error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + stripe_customer_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )`, + + `CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )`, + + `CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + stripe_subscription_id TEXT NOT NULL UNIQUE, + stripe_price_id TEXT NOT NULL DEFAULT '', + plan_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + current_period_end TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )`, + + `CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + stripe_invoice_id TEXT NOT NULL UNIQUE, + amount_cents INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'usd', + status TEXT NOT NULL DEFAULT 'open', + invoice_pdf_url TEXT NOT NULL DEFAULT '', + period_start TEXT NOT NULL DEFAULT '', + period_end TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )`, + + `CREATE TABLE IF NOT EXISTS password_resets ( + token TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0 + )`, + } + + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + return fmt.Errorf("exec schema: %w", err) + } + } + return nil +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..9644374 --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,94 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "os" +) + +// Config holds outbound mail settings read from environment variables. +type Config struct { + Host string // SMTP_HOST + Port string // SMTP_PORT + User string // SMTP_USER + Pass string // SMTP_PASS + From string // SMTP_FROM +} + +// ConfigFromEnv reads SMTP settings from environment variables. +func ConfigFromEnv() Config { + port := os.Getenv("SMTP_PORT") + if port == "" { + port = "587" + } + return Config{ + Host: os.Getenv("SMTP_HOST"), + Port: port, + User: os.Getenv("SMTP_USER"), + Pass: os.Getenv("SMTP_PASS"), + From: os.Getenv("SMTP_FROM"), + } +} + +// Ready reports whether all required SMTP fields are set. +func (c Config) Ready() bool { + return c.Host != "" && c.User != "" && c.Pass != "" && c.From != "" +} + +// sendEmail sends a plain-text email to a single recipient. +// It tries implicit TLS first, then falls back to STARTTLS via smtp.SendMail. +func sendEmail(cfg Config, to, subject, body string) error { + addr := cfg.Host + ":" + cfg.Port + auth := smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host) + + msg := fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", + cfg.From, to, subject, body, + ) + + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host}) + if err != nil { + return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg)) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return err + } + defer client.Close() + + if err = client.Auth(auth); err != nil { + return err + } + if err = client.Mail(cfg.From); err != nil { + return err + } + if err = client.Rcpt(to); err != nil { + return err + } + wc, err := client.Data() + if err != nil { + return err + } + if _, err = fmt.Fprint(wc, msg); err != nil { + return err + } + return wc.Close() +} + +// SendPasswordReset sends a password-reset email containing resetURL. +func SendPasswordReset(cfg Config, toEmail, resetURL string) error { + subject := "Reset your Arcline IT password" + body := fmt.Sprintf( + "Hello,\r\n\r\n"+ + "A password reset was requested for your Arcline IT account.\r\n\r\n"+ + "Click the link below to set a new password (valid for 1 hour):\r\n\r\n"+ + "%s\r\n\r\n"+ + "If you did not request this, you can safely ignore this email.\r\n\r\n"+ + "-- Arcline IT Support\r\n", + resetURL, + ) + return sendEmail(cfg, toEmail, subject, body) +} diff --git a/internal/payments/payments.go b/internal/payments/payments.go new file mode 100644 index 0000000..51fde4e --- /dev/null +++ b/internal/payments/payments.go @@ -0,0 +1,216 @@ +package payments + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "time" + + stripelib "github.com/stripe/stripe-go/v81" + "github.com/stripe/stripe-go/v81/checkout/session" + stripecustomer "github.com/stripe/stripe-go/v81/customer" + "github.com/stripe/stripe-go/v81/subscription" +) + +// Config holds Stripe credentials loaded from environment variables. +type Config struct { + SecretKey string + WebhookSecret string + PriceIDs map[string]string // plan key → Stripe price ID +} + +// ConfigFromEnv reads Stripe settings from environment variables. +func ConfigFromEnv() Config { + return Config{ + SecretKey: os.Getenv("STRIPE_SECRET_KEY"), + WebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), + PriceIDs: map[string]string{ + "shared_starter": os.Getenv("STRIPE_PRICE_SHARED_STARTER"), + "shared_pro": os.Getenv("STRIPE_PRICE_SHARED_PRO"), + "shared_business": os.Getenv("STRIPE_PRICE_SHARED_BUSINESS"), + "wp_starter": os.Getenv("STRIPE_PRICE_WP_STARTER"), + "wp_pro": os.Getenv("STRIPE_PRICE_WP_PRO"), + "wp_business": os.Getenv("STRIPE_PRICE_WP_BUSINESS"), + "vps_1": os.Getenv("STRIPE_PRICE_VPS_1"), + "vps_2": os.Getenv("STRIPE_PRICE_VPS_2"), + "vps_3": os.Getenv("STRIPE_PRICE_VPS_3"), + "vps_4": os.Getenv("STRIPE_PRICE_VPS_4"), + }, + } +} + +// Ready reports whether the required Stripe credentials are set. +func (c Config) Ready() bool { + return c.SecretKey != "" && c.WebhookSecret != "" +} + +// CreateCustomer creates a Stripe customer and returns the Stripe customer ID. +func CreateCustomer(email, firstName, lastName string) (string, error) { + params := &stripelib.CustomerParams{ + Email: stripelib.String(email), + Name: stripelib.String(firstName + " " + lastName), + } + c, err := stripecustomer.New(params) + if err != nil { + return "", fmt.Errorf("stripe create customer: %w", err) + } + return c.ID, nil +} + +// CreateCheckoutSession creates a Stripe Checkout session and returns the hosted URL. +func CreateCheckoutSession( + cfg Config, + customerID int64, + stripeCustomerID, + priceID, + successURL, + cancelURL string, +) (string, error) { + params := &stripelib.CheckoutSessionParams{ + Customer: stripelib.String(stripeCustomerID), + Mode: stripelib.String(string(stripelib.CheckoutSessionModeSubscription)), + LineItems: []*stripelib.CheckoutSessionLineItemParams{ + { + Price: stripelib.String(priceID), + Quantity: stripelib.Int64(1), + }, + }, + SuccessURL: stripelib.String(successURL), + CancelURL: stripelib.String(cancelURL), + Metadata: map[string]string{ + "customer_id": fmt.Sprintf("%d", customerID), + }, + } + + s, err := session.New(params) + if err != nil { + return "", fmt.Errorf("stripe checkout session: %w", err) + } + return s.URL, nil +} + +// CancelSubscription cancels a Stripe subscription by ID. +func CancelSubscription(stripeSubID string) error { + params := &stripelib.SubscriptionCancelParams{} + _, err := subscription.Cancel(stripeSubID, params) + if err != nil { + return fmt.Errorf("stripe cancel subscription: %w", err) + } + return nil +} + +// HandleCheckoutCompleted processes a checkout.session.completed webhook event. +func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error { + var cs stripelib.CheckoutSession + if err := json.Unmarshal(raw, &cs); err != nil { + return fmt.Errorf("unmarshal checkout session: %w", err) + } + + if cs.Subscription == nil { + return nil + } + + customerIDStr, ok := cs.Metadata["customer_id"] + if !ok { + return fmt.Errorf("missing customer_id in metadata") + } + + var customerID int64 + fmt.Sscanf(customerIDStr, "%d", &customerID) + + now := time.Now().UTC().Format(time.RFC3339) + _, err := db.Exec( + `INSERT INTO subscriptions + (customer_id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end, created_at, updated_at) + VALUES (?, ?, ?, ?, 'active', '', ?, ?) + ON CONFLICT(stripe_subscription_id) DO UPDATE SET + status = 'active', + updated_at = excluded.updated_at`, + customerID, cs.Subscription.ID, "", "", now, now, + ) + return err +} + +// HandleInvoicePaid processes an invoice.paid webhook event. +func HandleInvoicePaid(db *sql.DB, raw json.RawMessage) error { + var inv stripelib.Invoice + if err := json.Unmarshal(raw, &inv); err != nil { + return fmt.Errorf("unmarshal invoice: %w", err) + } + + var customerID int64 + if inv.Customer != nil { + err := db.QueryRow( + `SELECT id FROM customers WHERE stripe_customer_id = ?`, inv.Customer.ID, + ).Scan(&customerID) + if err != nil { + return fmt.Errorf("find customer for stripe id %s: %w", inv.Customer.ID, err) + } + } + + pdfURL := "" + if inv.InvoicePDF != "" { + pdfURL = inv.InvoicePDF + } + + periodStart := time.Unix(inv.PeriodStart, 0).UTC().Format(time.RFC3339) + periodEnd := time.Unix(inv.PeriodEnd, 0).UTC().Format(time.RFC3339) + now := time.Now().UTC().Format(time.RFC3339) + + _, err := db.Exec( + `INSERT INTO invoices + (customer_id, stripe_invoice_id, amount_cents, currency, status, invoice_pdf_url, period_start, period_end, created_at) + VALUES (?, ?, ?, ?, 'paid', ?, ?, ?, ?) + ON CONFLICT(stripe_invoice_id) DO UPDATE SET + status = 'paid', + invoice_pdf_url = excluded.invoice_pdf_url`, + customerID, inv.ID, inv.AmountPaid, string(inv.Currency), pdfURL, periodStart, periodEnd, now, + ) + if err != nil { + return fmt.Errorf("upsert invoice: %w", err) + } + + if inv.Subscription != nil { + _, _ = db.Exec( + `UPDATE subscriptions SET current_period_end = ?, updated_at = ? WHERE stripe_subscription_id = ?`, + periodEnd, now, inv.Subscription.ID, + ) + } + + return nil +} + +// HandleInvoicePaymentFailed processes an invoice.payment_failed webhook event. +func HandleInvoicePaymentFailed(db *sql.DB, raw json.RawMessage) error { + var inv stripelib.Invoice + if err := json.Unmarshal(raw, &inv); err != nil { + return fmt.Errorf("unmarshal invoice: %w", err) + } + + if inv.Subscription == nil { + return nil + } + + now := time.Now().UTC().Format(time.RFC3339) + _, err := db.Exec( + `UPDATE subscriptions SET status = 'past_due', updated_at = ? WHERE stripe_subscription_id = ?`, + now, inv.Subscription.ID, + ) + return err +} + +// HandleSubscriptionDeleted processes a customer.subscription.deleted webhook event. +func HandleSubscriptionDeleted(db *sql.DB, raw json.RawMessage) error { + var sub stripelib.Subscription + if err := json.Unmarshal(raw, &sub); err != nil { + return fmt.Errorf("unmarshal subscription: %w", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + _, err := db.Exec( + `UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`, + now, sub.ID, + ) + return err +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..b83a48e --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,652 @@ +package web + +import ( + "database/sql" + "embed" + "errors" + "html/template" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/stripe/stripe-go/v81/webhook" + + "arclineit.com/billing/internal/auth" + "arclineit.com/billing/internal/mail" + "arclineit.com/billing/internal/payments" +) + +//go:embed templates +var templateFS embed.FS + +// Handler holds all HTTP handler dependencies. +type Handler struct { + DB *sql.DB + Stripe payments.Config + SMTP mail.Config + BaseURL string + ts *templateSet +} + +// New creates a Handler and pre-parses all HTML templates. +func New(db *sql.DB, stripe payments.Config, smtp mail.Config, baseURL string) (*Handler, error) { + ts, err := loadTemplates() + if err != nil { + return nil, err + } + return &Handler{DB: db, Stripe: stripe, SMTP: smtp, BaseURL: baseURL, ts: ts}, nil +} + +// ---- template set ---- + +type templateSet struct { + tmpl *template.Template +} + +var templateFuncs = template.FuncMap{ + "slice": func(start, end int, s string) string { + runes := []rune(s) + if start < 0 { + start = 0 + } + if end > len(runes) { + end = len(runes) + } + if start > end { + return "" + } + return string(runes[start:end]) + }, + "fmtDate": func(s string) string { + if s == "" { + return "—" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return t.Format("Jan 2, 2006") + }, +} + +func loadTemplates() (*templateSet, error) { + tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templateFS, "templates/*.html") + if err != nil { + return nil, err + } + return &templateSet{tmpl: tmpl}, nil +} + +func (ts *templateSet) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := ts.tmpl.ExecuteTemplate(w, name, data); err != nil { + slog.Error("template render", "name", name, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + } +} + +// ---- data models ---- + +type customer struct { + ID int64 + Email string + FirstName string + LastName string + StripeCustomerID string + CreatedAt string +} + +type subscriptionRow struct { + ID int64 + StripeSubscriptionID string + StripePriceID string + PlanName string + Status string + CurrentPeriodEnd string +} + +type invoiceRow struct { + ID int64 + StripeInvoiceID string + AmountCents int64 + Currency string + Status string + InvoicePDFURL string + PeriodStart string + PeriodEnd string + CreatedAt string + AmountDisplay string +} + +type dashboardData struct { + Customer customer + Subscription *subscriptionRow + Invoices []invoiceRow + Flash string +} + +// ---- DB helpers ---- + +func loadCustomer(db *sql.DB, id int64) (customer, error) { + var c customer + err := db.QueryRow( + `SELECT id, email, first_name, last_name, stripe_customer_id, created_at FROM customers WHERE id = ?`, + id, + ).Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName, &c.StripeCustomerID, &c.CreatedAt) + return c, err +} + +func loadSubscription(db *sql.DB, customerID int64) (*subscriptionRow, error) { + var s subscriptionRow + err := db.QueryRow( + `SELECT id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end + FROM subscriptions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1`, + customerID, + ).Scan(&s.ID, &s.StripeSubscriptionID, &s.StripePriceID, &s.PlanName, &s.Status, &s.CurrentPeriodEnd) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &s, err +} + +func loadRecentInvoices(db *sql.DB, customerID int64) ([]invoiceRow, error) { + rows, err := db.Query( + `SELECT id, stripe_invoice_id, amount_cents, currency, status, invoice_pdf_url, + period_start, period_end, created_at + FROM invoices WHERE customer_id = ? ORDER BY created_at DESC LIMIT 5`, + customerID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []invoiceRow + for rows.Next() { + var inv invoiceRow + if err := rows.Scan( + &inv.ID, &inv.StripeInvoiceID, &inv.AmountCents, &inv.Currency, + &inv.Status, &inv.InvoicePDFURL, &inv.PeriodStart, &inv.PeriodEnd, &inv.CreatedAt, + ); err != nil { + return nil, err + } + dollars := inv.AmountCents / 100 + cents := inv.AmountCents % 100 + inv.AmountDisplay = formatCurrency(dollars, cents, strings.ToUpper(inv.Currency)) + result = append(result, inv) + } + return result, rows.Err() +} + +func formatCurrency(dollars, cents int64, currency string) string { + if currency == "USD" || currency == "" { + return "$" + itoa(dollars) + "." + pad2(cents) + } + return itoa(dollars) + "." + pad2(cents) + " " + currency +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + s := "" + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + s = string(rune('0'+n%10)) + s + n /= 10 + } + if neg { + s = "-" + s + } + return s +} + +func pad2(n int64) string { + if n < 10 { + return "0" + itoa(n) + } + return itoa(n) +} + +// ---- session cookie helpers ---- + +func sessionSecure() bool { + return os.Getenv("SESSION_SECURE") != "false" +} + +func setSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + MaxAge: int(auth.SessionTTL.Seconds()), + HttpOnly: true, + Secure: sessionSecure(), + SameSite: http.SameSiteStrictMode, + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: sessionSecure(), + SameSite: http.SameSiteStrictMode, + }) +} + +// ---- route handlers ---- + +func (h *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + cookie, err := r.Cookie("session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + _, err = auth.GetSession(h.DB, cookie.Value) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +func (h *Handler) LoginGET(w http.ResponseWriter, r *http.Request) { + h.ts.render(w, "login.html", map[string]any{ + "Error": r.URL.Query().Get("error"), + }) +} + +func (h *Handler) LoginPOST(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + password := r.FormValue("password") + + if email == "" || password == "" { + http.Redirect(w, r, "/login?error=missing_fields", http.StatusSeeOther) + return + } + + var id int64 + var hash string + err := h.DB.QueryRow( + `SELECT id, password_hash FROM customers WHERE email = ?`, email, + ).Scan(&id, &hash) + + if errors.Is(err, sql.ErrNoRows) || !auth.CheckPassword(hash, password) { + http.Redirect(w, r, "/login?error=invalid_credentials", http.StatusSeeOther) + return + } + if err != nil { + slog.Error("login: db query", "err", err) + http.Redirect(w, r, "/login?error=server_error", http.StatusSeeOther) + return + } + + token, err := auth.CreateSession(h.DB, id) + if err != nil { + slog.Error("login: create session", "err", err) + http.Redirect(w, r, "/login?error=server_error", http.StatusSeeOther) + return + } + + setSessionCookie(w, token) + slog.Info("customer logged in", "customer_id", id, "email", email) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +func (h *Handler) RegisterGET(w http.ResponseWriter, r *http.Request) { + h.ts.render(w, "register.html", map[string]any{ + "Error": r.URL.Query().Get("error"), + }) +} + +func (h *Handler) RegisterPOST(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + firstName := strings.TrimSpace(r.FormValue("first_name")) + lastName := strings.TrimSpace(r.FormValue("last_name")) + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + password := r.FormValue("password") + confirm := r.FormValue("confirm_password") + + if firstName == "" || lastName == "" || email == "" || password == "" { + http.Redirect(w, r, "/register?error=missing_fields", http.StatusSeeOther) + return + } + if password != confirm { + http.Redirect(w, r, "/register?error=password_mismatch", http.StatusSeeOther) + return + } + if len(password) < 8 { + http.Redirect(w, r, "/register?error=password_too_short", http.StatusSeeOther) + return + } + + var existing int64 + _ = h.DB.QueryRow(`SELECT id FROM customers WHERE email = ?`, email).Scan(&existing) + if existing != 0 { + http.Redirect(w, r, "/register?error=email_taken", http.StatusSeeOther) + return + } + + hash, err := auth.HashPassword(password) + if err != nil { + slog.Error("register: hash password", "err", err) + http.Redirect(w, r, "/register?error=server_error", http.StatusSeeOther) + return + } + + stripeCustomerID := "" + if h.Stripe.Ready() { + stripeCustomerID, err = payments.CreateCustomer(email, firstName, lastName) + if err != nil { + slog.Error("register: create stripe customer", "err", err) + } + } + + res, err := h.DB.Exec( + `INSERT INTO customers (email, password_hash, first_name, last_name, stripe_customer_id) VALUES (?, ?, ?, ?, ?)`, + email, hash, firstName, lastName, stripeCustomerID, + ) + if err != nil { + slog.Error("register: insert customer", "err", err) + http.Redirect(w, r, "/register?error=server_error", http.StatusSeeOther) + return + } + + customerID, _ := res.LastInsertId() + + token, err := auth.CreateSession(h.DB, customerID) + if err != nil { + slog.Error("register: create session", "err", err) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + setSessionCookie(w, token) + slog.Info("customer registered", "customer_id", customerID, "email", email) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) { + customerID := auth.CustomerIDFromContext(r.Context()) + + c, err := loadCustomer(h.DB, customerID) + if err != nil { + slog.Error("dashboard: load customer", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + sub, err := loadSubscription(h.DB, customerID) + if err != nil { + slog.Error("dashboard: load subscription", "err", err) + } + + invoices, err := loadRecentInvoices(h.DB, customerID) + if err != nil { + slog.Error("dashboard: load invoices", "err", err) + } + + flash := "" + switch r.URL.Query().Get("checkout") { + case "success": + flash = "Your subscription is being activated. It may take a moment to appear." + case "cancelled": + flash = "Checkout was cancelled. No charge was made." + } + if r.URL.Query().Get("cancelled") == "1" { + flash = "Your subscription has been cancelled." + } + if r.URL.Query().Get("error") == "cancel_failed" { + flash = "Could not cancel subscription. Please contact support." + } + + h.ts.render(w, "dashboard.html", dashboardData{ + Customer: c, + Subscription: sub, + Invoices: invoices, + Flash: flash, + }) +} + +func (h *Handler) LogoutPOST(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err == nil { + _ = auth.DeleteSession(h.DB, cookie.Value) + } + clearSessionCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (h *Handler) ResetGET(w http.ResponseWriter, r *http.Request) { + h.ts.render(w, "reset-request.html", map[string]any{ + "Sent": r.URL.Query().Get("sent"), + "Error": r.URL.Query().Get("error"), + }) +} + +func (h *Handler) ResetPOST(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + if email == "" { + http.Redirect(w, r, "/reset?error=missing_email", http.StatusSeeOther) + return + } + + var customerID int64 + err := h.DB.QueryRow(`SELECT id FROM customers WHERE email = ?`, email).Scan(&customerID) + + if err == nil { + token, tokenErr := auth.CreateResetToken(h.DB, customerID) + if tokenErr != nil { + slog.Error("reset: create token", "err", tokenErr) + } else if h.SMTP.Ready() { + resetURL := h.BaseURL + "/reset/" + token + if mailErr := mail.SendPasswordReset(h.SMTP, email, resetURL); mailErr != nil { + slog.Error("reset: send email", "err", mailErr) + } + } else { + slog.Warn("reset: SMTP not configured, reset token not sent", "email", email) + } + } + + http.Redirect(w, r, "/reset?sent=1", http.StatusSeeOther) +} + +func (h *Handler) ResetConfirmGET(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + h.ts.render(w, "reset-confirm.html", map[string]any{ + "Token": token, + "Error": r.URL.Query().Get("error"), + }) +} + +func (h *Handler) ResetConfirmPOST(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + password := r.FormValue("password") + confirm := r.FormValue("confirm_password") + + if password == "" { + http.Redirect(w, r, "/reset/"+token+"?error=missing_password", http.StatusSeeOther) + return + } + if password != confirm { + http.Redirect(w, r, "/reset/"+token+"?error=password_mismatch", http.StatusSeeOther) + return + } + if len(password) < 8 { + http.Redirect(w, r, "/reset/"+token+"?error=password_too_short", http.StatusSeeOther) + return + } + + customerID, err := auth.ValidateResetToken(h.DB, token) + if err != nil { + slog.Warn("reset confirm: invalid token", "err", err) + http.Redirect(w, r, "/reset/"+token+"?error=invalid_token", http.StatusSeeOther) + return + } + + hash, err := auth.HashPassword(password) + if err != nil { + slog.Error("reset confirm: hash password", "err", err) + http.Redirect(w, r, "/reset/"+token+"?error=server_error", http.StatusSeeOther) + return + } + + _, err = h.DB.Exec( + `UPDATE customers SET password_hash = ? WHERE id = ?`, hash, customerID, + ) + if err != nil { + slog.Error("reset confirm: update password", "err", err) + http.Redirect(w, r, "/reset/"+token+"?error=server_error", http.StatusSeeOther) + return + } + + if err := auth.ConsumeResetToken(h.DB, token); err != nil { + slog.Error("reset confirm: consume token", "err", err) + } + + _, _ = h.DB.Exec(`DELETE FROM sessions WHERE customer_id = ?`, customerID) + + slog.Info("password reset completed", "customer_id", customerID) + http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther) +} + +func (h *Handler) CheckoutGET(w http.ResponseWriter, r *http.Request) { + customerID := auth.CustomerIDFromContext(r.Context()) + priceID := r.URL.Query().Get("plan") + if priceID == "" { + http.Error(w, "missing plan parameter", http.StatusBadRequest) + return + } + + var stripeCustomerID string + err := h.DB.QueryRow( + `SELECT stripe_customer_id FROM customers WHERE id = ?`, customerID, + ).Scan(&stripeCustomerID) + if err != nil { + slog.Error("checkout: load customer", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + successURL := h.BaseURL + "/dashboard?checkout=success" + cancelURL := h.BaseURL + "/dashboard?checkout=cancelled" + + url, err := payments.CreateCheckoutSession(h.Stripe, customerID, stripeCustomerID, priceID, successURL, cancelURL) + if err != nil { + slog.Error("checkout: create session", "err", err) + http.Error(w, "could not create checkout session", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, url, http.StatusSeeOther) +} + +func (h *Handler) WebhookPOST(w http.ResponseWriter, r *http.Request) { + const maxBodyBytes = 65536 + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + + payload, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body failed", http.StatusBadRequest) + return + } + + sig := r.Header.Get("Stripe-Signature") + event, err := webhook.ConstructEvent(payload, sig, h.Stripe.WebhookSecret) + if err != nil { + slog.Warn("webhook: signature verification failed", "err", err) + http.Error(w, "invalid signature", http.StatusBadRequest) + return + } + + slog.Info("webhook received", "type", event.Type) + + switch event.Type { + case "checkout.session.completed": + if err := payments.HandleCheckoutCompleted(h.DB, event.Data.Raw); err != nil { + slog.Error("webhook: checkout.session.completed", "err", err) + } + case "invoice.paid": + if err := payments.HandleInvoicePaid(h.DB, event.Data.Raw); err != nil { + slog.Error("webhook: invoice.paid", "err", err) + } + case "invoice.payment_failed": + if err := payments.HandleInvoicePaymentFailed(h.DB, event.Data.Raw); err != nil { + slog.Error("webhook: invoice.payment_failed", "err", err) + } + case "customer.subscription.deleted": + if err := payments.HandleSubscriptionDeleted(h.DB, event.Data.Raw); err != nil { + slog.Error("webhook: customer.subscription.deleted", "err", err) + } + default: + slog.Info("webhook: unhandled event type", "type", event.Type) + } + + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) CancelPOST(w http.ResponseWriter, r *http.Request) { + customerID := auth.CustomerIDFromContext(r.Context()) + + var stripeSubID string + err := h.DB.QueryRow( + `SELECT stripe_subscription_id FROM subscriptions + WHERE customer_id = ? AND status = 'active' + ORDER BY created_at DESC LIMIT 1`, + customerID, + ).Scan(&stripeSubID) + if err != nil { + slog.Error("cancel: find subscription", "err", err) + http.Redirect(w, r, "/dashboard?error=no_subscription", http.StatusSeeOther) + return + } + + if err := payments.CancelSubscription(stripeSubID); err != nil { + slog.Error("cancel: stripe cancel", "err", err) + http.Redirect(w, r, "/dashboard?error=cancel_failed", http.StatusSeeOther) + return + } + + now := time.Now().UTC().Format(time.RFC3339) + _, _ = h.DB.Exec( + `UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`, + now, stripeSubID, + ) + + slog.Info("subscription cancelled", "customer_id", customerID, "stripe_sub_id", stripeSubID) + http.Redirect(w, r, "/dashboard?cancelled=1", http.StatusSeeOther) +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go new file mode 100644 index 0000000..3017cdf --- /dev/null +++ b/internal/web/middleware.go @@ -0,0 +1,150 @@ +package web + +import ( + "context" + "log/slog" + "net" + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// securityHeaders are applied to every response. +var securityHeaders = map[string]string{ + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "SAMEORIGIN", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()", + "Content-Security-Policy": "default-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "font-src 'self'; " + + "script-src 'self'; " + + "img-src 'self' data:; " + + "connect-src 'self'; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'", +} + +func applySecurityHeaders(w http.ResponseWriter) { + for k, v := range securityHeaders { + if w.Header().Get(k) == "" { + w.Header().Set(k, v) + } + } +} + +// ---- per-IP rate limiter ---- + +type ipLimiter struct { + limiter *rate.Limiter + lastSeen time.Time +} + +// RateLimiter tracks per-IP request rates. +type RateLimiter struct { + mu sync.Mutex + limiters map[string]*ipLimiter + r rate.Limit + burst int +} + +// NewRateLimiter creates a RateLimiter with the given sustained rate and burst. +func NewRateLimiter(r rate.Limit, burst int) *RateLimiter { + rl := &RateLimiter{ + limiters: make(map[string]*ipLimiter), + r: r, + burst: burst, + } + go rl.cleanup() + return rl +} + +func (rl *RateLimiter) get(ip string) *rate.Limiter { + rl.mu.Lock() + defer rl.mu.Unlock() + + entry, ok := rl.limiters[ip] + if !ok { + entry = &ipLimiter{limiter: rate.NewLimiter(rl.r, rl.burst)} + rl.limiters[ip] = entry + } + entry.lastSeen = time.Now() + return entry.limiter +} + +func (rl *RateLimiter) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + rl.mu.Lock() + for ip, e := range rl.limiters { + if time.Since(e.lastSeen) > 10*time.Minute { + delete(rl.limiters, ip) + } + } + rl.mu.Unlock() + } +} + +// ---- status recorder for logging ---- + +type statusRecorder struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func (sr *statusRecorder) WriteHeader(code int) { + if !sr.wroteHeader { + sr.status = code + sr.wroteHeader = true + } + sr.ResponseWriter.WriteHeader(code) +} + +func (sr *statusRecorder) Write(b []byte) (int, error) { + if !sr.wroteHeader { + sr.status = http.StatusOK + sr.wroteHeader = true + } + return sr.ResponseWriter.Write(b) +} + +// BuildMiddleware wraps mux with: logging → timeout → rate-limit → security headers. +func BuildMiddleware(mux http.Handler, rl *RateLimiter, timeout time.Duration) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + r = r.WithContext(ctx) + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + + if !rl.get(ip).Allow() { + http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests) + slog.Info("rate limited", "ip", ip, "path", r.URL.Path) + return + } + + applySecurityHeaders(w) + + sr := &statusRecorder{ResponseWriter: w} + mux.ServeHTTP(sr, r) + + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", sr.status, + "ip", ip, + "duration", time.Since(start).String(), + ) + }) +} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html new file mode 100644 index 0000000..0f4736e --- /dev/null +++ b/internal/web/templates/base.html @@ -0,0 +1,39 @@ +{{define "base"}} + + + + + {{block "title" .}}Arcline IT — Client Portal{{end}} + + + + + +
+ {{block "content" .}}{{end}} +
+ + + + +{{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html new file mode 100644 index 0000000..c000168 --- /dev/null +++ b/internal/web/templates/dashboard.html @@ -0,0 +1,144 @@ +{{template "base" .}} + +{{define "title"}}Dashboard — Arcline IT Client Portal{{end}} + +{{define "nav-actions"}} +
+ +
+{{end}} + +{{define "content"}} +
+
+ + {{if .Flash}} +
{{.Flash}}
+ {{end}} + +
+

+ Welcome back, {{.Customer.FirstName}} +

+ +
+ + +
+

// subscription

+ + {{if .Subscription}} +
+
+ + + + subscription.status +
+
+
+ plan + + {{if .Subscription.PlanName}}{{.Subscription.PlanName}}{{else}}active plan{{end}} +
+
+ status + + {{.Subscription.Status}} +
+ {{if .Subscription.CurrentPeriodEnd}} +
+ next billing date + + {{.Subscription.CurrentPeriodEnd}} +
+ {{end}} +
+
+ + {{if eq .Subscription.Status "active"}} +
+
+ +
+
+ {{end}} + + {{else}} +
+
+ + + + subscription.status +
+
+

No active subscription.

+

+ View plans to get started. +

+
+
+ {{end}} +
+ + +
+

// recent invoices

+ + {{if .Invoices}} +
+
+ + + + invoices +
+
+ + + + + + + + + + + + {{range .Invoices}} + + + + + + + + {{end}} + +
dateperiodamountstatuspdf
{{.CreatedAt | slice 0 10}}{{.PeriodStart | slice 0 10}} – {{.PeriodEnd | slice 0 10}}{{.AmountDisplay}}{{.Status}} + {{if .InvoicePDFURL}} + download + {{else}}—{{end}} +
+
+
+ {{else}} +
+
+ + + + invoices +
+
+

No invoices yet.

+
+
+ {{end}} +
+ +
+
+{{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..056e170 --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,49 @@ +{{template "base" .}} + +{{define "title"}}Sign In — Arcline IT Client Portal{{end}} + +{{define "content"}} +
+
+
+ $ arcline-billing --login +
+
+

Sign in to your account

+ + {{if .Error}} +
+ {{if eq .Error "invalid_credentials"}}Invalid email or password. + {{else if eq .Error "missing_fields"}}Please fill in all fields. + {{else if eq .Error "server_error"}}A server error occurred. Please try again. + {{else}}An error occurred. Please try again.{{end}} +
+ {{end}} + + {{if eq (index . "reset") "1"}} +
Password updated. You can now sign in.
+ {{end}} + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+{{end}} diff --git a/internal/web/templates/register.html b/internal/web/templates/register.html new file mode 100644 index 0000000..59de765 --- /dev/null +++ b/internal/web/templates/register.html @@ -0,0 +1,63 @@ +{{template "base" .}} + +{{define "title"}}Create Account — Arcline IT Client Portal{{end}} + +{{define "content"}} +
+
+
+ $ arcline-billing --register +
+
+

Create your account

+ + {{if .Error}} +
+ {{if eq .Error "missing_fields"}}Please fill in all required fields. + {{else if eq .Error "password_mismatch"}}Passwords do not match. + {{else if eq .Error "password_too_short"}}Password must be at least 8 characters. + {{else if eq .Error "email_taken"}}An account with that email already exists. + {{else if eq .Error "server_error"}}A server error occurred. Please try again. + {{else}}An error occurred. Please try again.{{end}} +
+ {{end}} + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+{{end}} diff --git a/internal/web/templates/reset-confirm.html b/internal/web/templates/reset-confirm.html new file mode 100644 index 0000000..cff8720 --- /dev/null +++ b/internal/web/templates/reset-confirm.html @@ -0,0 +1,45 @@ +{{template "base" .}} + +{{define "title"}}Set New Password — Arcline IT Client Portal{{end}} + +{{define "content"}} +
+
+
+ $ arcline-billing --set-password +
+
+

Set a new password

+ + {{if .Error}} +
+ {{if eq .Error "invalid_token"}}This reset link is invalid or has expired. + {{else if eq .Error "missing_password"}}Please enter a new password. + {{else if eq .Error "password_mismatch"}}Passwords do not match. + {{else if eq .Error "password_too_short"}}Password must be at least 8 characters. + {{else if eq .Error "server_error"}}A server error occurred. Please try again. + {{else}}An error occurred. Please try again.{{end}} +
+ {{end}} + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+{{end}} diff --git a/internal/web/templates/reset-request.html b/internal/web/templates/reset-request.html new file mode 100644 index 0000000..e3f305c --- /dev/null +++ b/internal/web/templates/reset-request.html @@ -0,0 +1,48 @@ +{{template "base" .}} + +{{define "title"}}Reset Password — Arcline IT Client Portal{{end}} + +{{define "content"}} +
+
+
+ $ arcline-billing --reset-password +
+
+

Reset your password

+ + {{if .Sent}} +
+ If an account exists for that email, a reset link has been sent. Check your inbox. +
+ {{else}} + + {{if .Error}} +
+ {{if eq .Error "missing_email"}}Please enter your email address. + {{else}}An error occurred. Please try again.{{end}} +
+ {{end}} + +

+ Enter your account email and we'll send you a link to reset your password. +

+ +
+
+ + +
+ +
+ + {{end}} + + +
+
+
+{{end}} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0234a66 --- /dev/null +++ b/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" + stripelib "github.com/stripe/stripe-go/v81" + "golang.org/x/time/rate" + + "arclineit.com/billing/internal/auth" + "arclineit.com/billing/internal/db" + "arclineit.com/billing/internal/mail" + "arclineit.com/billing/internal/payments" + "arclineit.com/billing/internal/web" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + if err := godotenv.Load(); err != nil { + slog.Info(".env not found, using system environment") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8082" + } + + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:" + port + } + + dbPath := os.Getenv("DATABASE_PATH") + if dbPath == "" { + dbPath = "./arcline-billing.db" + } + + database, err := db.Open(dbPath) + if err != nil { + slog.Error("failed to open database", "err", err) + os.Exit(1) + } + defer database.Close() + + smtpCfg := mail.ConfigFromEnv() + if !smtpCfg.Ready() { + slog.Warn("SMTP not configured: password reset emails will not be sent. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM.") + } + + stripeCfg := payments.ConfigFromEnv() + if !stripeCfg.Ready() { + slog.Warn("Stripe not configured: payments will not work. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.") + } else { + stripelib.Key = stripeCfg.SecretKey + } + + h, err := web.New(database, stripeCfg, smtpCfg, baseURL) + if err != nil { + slog.Error("failed to init handlers", "err", err) + os.Exit(1) + } + + mux := http.NewServeMux() + + // Static assets. + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + // Public routes. + mux.HandleFunc("GET /{$}", h.IndexHandler) + mux.HandleFunc("GET /login", h.LoginGET) + mux.HandleFunc("POST /login", h.LoginPOST) + mux.HandleFunc("GET /register", h.RegisterGET) + mux.HandleFunc("POST /register", h.RegisterPOST) + mux.HandleFunc("POST /logout", h.LogoutPOST) + mux.HandleFunc("GET /reset", h.ResetGET) + mux.HandleFunc("POST /reset", h.ResetPOST) + mux.HandleFunc("GET /reset/{token}", h.ResetConfirmGET) + mux.HandleFunc("POST /reset/{token}", h.ResetConfirmPOST) + + // Stripe webhook — no auth (Stripe signs the payload instead). + mux.HandleFunc("POST /webhook", h.WebhookPOST) + + // Authenticated routes. + authed := auth.Middleware(database) + mux.Handle("GET /dashboard", authed(http.HandlerFunc(h.DashboardGET))) + mux.Handle("GET /checkout", authed(http.HandlerFunc(h.CheckoutGET))) + mux.Handle("POST /cancel", authed(http.HandlerFunc(h.CancelPOST))) + + // Rate limiter: 60 req/s sustained, burst 120. + rl := web.NewRateLimiter(rate.Limit(60), 120) + handler := web.BuildMiddleware(mux, rl, 10*time.Second) + + addr := "0.0.0.0:" + port + slog.Info("arcline-billing listening", "addr", "http://"+addr) + + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + stop() + slog.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown error", "err", err) + os.Exit(1) + } + slog.Info("shutdown complete") +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..81588e9 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,502 @@ +/* ============================================================ + ARCLINE BILLING — Terminal / Monospace Design System + Matches arcline website design tokens. + ============================================================ */ + +/* --- Reset --- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; font-size: 16px; -webkit-text-size-adjust: 100%; } +img, svg { display: block; max-width: 100%; } +a { color: inherit; text-decoration: none; } +ul, ol { list-style: none; } +button { cursor: pointer; border: none; background: none; font: inherit; } +input, textarea, select { font: inherit; } + +/* --- Design Tokens --- */ +:root { + --bg: #060b10; + --surface: #0c1319; + --surface-2: #121c25; + --border: #1c2a34; + --border-bright: #27394a; + --border-dim: #111c24; + + --cyan: #00c8f0; + --cyan-dim: #0090b8; + --cyan-bg: rgba(0, 200, 240, 0.06); + --cyan-border: rgba(0, 200, 240, 0.2); + + --text: #b8cdd8; + --text-dim: #456070; + --text-bright: #e0eff8; + --text-code: #7ab8d0; + + --ok: #00c8f0; + --warn: #f0a020; + --err: #e05050; + --ok-bg: rgba(0, 200, 240, 0.08); + --err-bg: rgba(224, 80, 80, 0.10); + --warn-bg: rgba(240, 160, 32, 0.10); + + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace; + --font-size-xs: 0.6875rem; + --font-size-sm: 0.75rem; + --font-size-md: 0.875rem; + --font-size-base: 0.9375rem; + --font-size-lg: 1.0625rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.75rem; + + --radius: 2px; + --nav-h: 56px; + --max-w: 1100px; + --t: 0.15s ease; +} + +/* --- Base --- */ +body { + font-family: var(--font-mono); + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; +} + +.main { flex: 1; } + +.container { + max-width: var(--max-w); + margin: 0 auto; + padding: 0 2rem; +} +@media (max-width: 640px) { .container { padding: 0 1.25rem; } } + +/* --- Blinking cursor --- */ +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +.cursor::after { + content: '▋'; + color: var(--cyan); + animation: blink 1s step-end infinite; + font-size: 0.85em; +} + +/* ============================================================ + NAVIGATION + ============================================================ */ +.nav { + position: sticky; + top: 0; + z-index: 100; + height: var(--nav-h); + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +.nav__inner { + max-width: var(--max-w); + margin: 0 auto; + padding: 0 2rem; + height: 100%; + display: flex; + align-items: center; + gap: 1rem; +} + +.nav__logo { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--text-bright); + letter-spacing: -0.02em; + white-space: nowrap; +} + +.nav__logo-bracket { color: var(--text-dim); } +.nav__logo-accent { color: var(--cyan); } + +.nav__label { + font-size: var(--font-size-sm); + color: var(--text-dim); + flex: 1; +} + +.nav__actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* ============================================================ + FOOTER + ============================================================ */ +.footer { + border-top: 1px solid var(--border); + padding: 1.25rem 0; + font-size: var(--font-size-sm); + color: var(--text-dim); +} + +.footer__inner { + max-width: var(--max-w); + margin: 0 auto; + padding: 0 2rem; + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} + +.footer__sep { color: var(--border-bright); } +.footer__link { color: var(--text-dim); transition: color var(--t); } +.footer__link:hover { color: var(--cyan); } + +/* ============================================================ + BUTTONS + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4em; + padding: 0.55em 1.2em; + font-size: var(--font-size-md); + font-weight: 600; + letter-spacing: 0.01em; + border-radius: var(--radius); + transition: background var(--t), color var(--t), border-color var(--t); + white-space: nowrap; + text-decoration: none; +} + +.btn--primary { + background: var(--cyan); + color: var(--bg); + border: 1px solid var(--cyan); +} +.btn--primary:hover { background: var(--cyan-dim); border-color: var(--cyan-dim); } + +.btn--ghost { + background: transparent; + color: var(--cyan); + border: 1px solid var(--cyan-border); +} +.btn--ghost:hover { background: var(--cyan-bg); border-color: var(--cyan); } + +.btn--danger { + background: transparent; + color: var(--err); + border: 1px solid rgba(224, 80, 80, 0.35); +} +.btn--danger:hover { background: var(--err-bg); border-color: var(--err); } + +.btn--muted { + background: transparent; + color: var(--text-dim); + border: 1px solid var(--border); +} +.btn--muted:hover { color: var(--text); border-color: var(--border-bright); } + +.btn--full { width: 100%; } +.btn--sm { font-size: var(--font-size-sm); padding: 0.35em 0.9em; } + +/* ============================================================ + ALERTS + ============================================================ */ +.alert { + padding: 0.75rem 1rem; + border-radius: var(--radius); + font-size: var(--font-size-md); + margin-bottom: 1.25rem; + border-left: 3px solid; +} +.alert--ok { background: var(--ok-bg); border-color: var(--ok); color: var(--ok); } +.alert--error { background: var(--err-bg); border-color: var(--err); color: var(--err); } +.alert--warn { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); } + +.flash { margin-bottom: 1.5rem; } + +/* ============================================================ + AUTH CARD (login / register / reset) + ============================================================ */ +.auth-wrap { + min-height: calc(100vh - var(--nav-h) - 60px); + display: flex; + align-items: center; + justify-content: center; + padding: 3rem 1.25rem; +} + +.auth-card { + width: 100%; + max-width: 440px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.auth-card__header { + background: var(--surface-2); + border-bottom: 1px solid var(--border); + padding: 0.6rem 1rem; + display: flex; + align-items: center; +} + +.auth-card__prompt { + font-size: var(--font-size-sm); + color: var(--cyan); + font-style: italic; +} + +.auth-card__body { padding: 2rem; } + +.auth-card__title { + font-size: var(--font-size-lg); + color: var(--text-bright); + font-weight: 700; + margin-bottom: 1.5rem; +} + +.auth-card__desc { + font-size: var(--font-size-md); + color: var(--text-dim); + margin-bottom: 1.25rem; +} + +.auth-card__links { + margin-top: 1.25rem; + text-align: center; + font-size: var(--font-size-sm); + color: var(--text-dim); + display: flex; + gap: 0.5rem; + justify-content: center; + align-items: center; +} + +.auth-card__link { color: var(--cyan); transition: color var(--t); } +.auth-card__link:hover { color: #fff; } +.auth-card__sep { color: var(--border-bright); } +.auth-card__text { color: var(--text-dim); } + +/* ============================================================ + FORMS + ============================================================ */ +.form { display: flex; flex-direction: column; gap: 1.1rem; } + +.form__row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} +@media (max-width: 420px) { .form__row { grid-template-columns: 1fr; } } + +.form__group { display: flex; flex-direction: column; gap: 0.4rem; } + +.form__label { + font-size: var(--font-size-sm); + color: var(--text-dim); + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.form__hint { color: var(--text-dim); font-size: 0.75em; } + +.form__input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-bright); + padding: 0.6em 0.85em; + font-size: var(--font-size-base); + transition: border-color var(--t), background var(--t); + width: 100%; +} +.form__input::placeholder { color: var(--text-dim); opacity: 0.5; } +.form__input:focus { + outline: none; + border-color: var(--cyan); + background: var(--surface-2); +} + +/* ============================================================ + DASHBOARD + ============================================================ */ +.dashboard { padding: 3rem 0 4rem; } + +.dash-header { + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.dash-header__title { + font-size: var(--font-size-2xl); + color: var(--text-bright); + font-weight: 700; + margin-bottom: 0.25rem; +} + +.dash-header__name { color: var(--cyan); } + +.dash-header__email { + font-size: var(--font-size-sm); + color: var(--text-dim); +} + +.dash-section { + margin-bottom: 2.5rem; +} + +.dash-section__title { + font-size: var(--font-size-md); + color: var(--text-dim); + text-transform: lowercase; + letter-spacing: 0.04em; + margin-bottom: 1rem; + font-weight: 600; +} + +.dash-actions { margin-top: 1rem; } + +.dash-empty { + color: var(--text-dim); + font-size: var(--font-size-md); + padding: 0.5rem 0; +} + +.dash-empty-sub { + color: var(--text-dim); + font-size: var(--font-size-sm); + margin-top: 0.5rem; +} + +/* ============================================================ + TERMINAL WINDOW CHROME + ============================================================ */ +.term-window { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); +} + +.term-window__chrome { + background: var(--surface-2); + border-bottom: 1px solid var(--border); + padding: 0.5rem 0.85rem; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.term-window__dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} +.term-window__dot--red { background: #e05050; } +.term-window__dot--yellow { background: #f0a020; } +.term-window__dot--green { background: #00c8a0; } + +.term-window__title { + margin-left: 0.5rem; + font-size: var(--font-size-sm); + color: var(--text-dim); +} + +.term-window__body { padding: 1.25rem 1.5rem; } + +/* ============================================================ + STATUS ROWS (inside term-window) + ============================================================ */ +.status-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.3rem 0; + font-size: var(--font-size-md); +} + +.status-row__label { + color: var(--text-dim); + white-space: nowrap; + min-width: 12rem; +} + +.status-row__dots { + flex: 1; + border-bottom: 1px dotted var(--border-bright); + margin-bottom: 3px; + min-width: 1rem; +} + +.status-row__value { + color: var(--text-bright); + white-space: nowrap; +} + +/* ============================================================ + STATUS BADGE + ============================================================ */ +.status-badge { + display: inline-block; + padding: 0.1em 0.55em; + border-radius: var(--radius); + font-size: var(--font-size-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.status-badge--active { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(0,200,240,0.25); } +.status-badge--cancelled { background: var(--err-bg); color: var(--err); border: 1px solid rgba(224,80,80,0.25); } +.status-badge--past_due { background: var(--warn-bg); color: var(--warn); border: 1px solid rgba(240,160,32,0.25); } +.status-badge--paid { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(0,200,240,0.25); } +.status-badge--open { background: var(--warn-bg); color: var(--warn); border: 1px solid rgba(240,160,32,0.25); } +.status-badge--void { background: var(--surface-2); color: var(--text-dim); border: 1px solid var(--border); } + +/* ============================================================ + INVOICE TABLE + ============================================================ */ +.inv-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); +} + +.inv-table__th { + text-align: left; + color: var(--text-dim); + font-weight: 600; + padding: 0.4rem 0.75rem; + border-bottom: 1px solid var(--border); + text-transform: lowercase; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.inv-table__th--right { text-align: right; } + +.inv-table__row:hover { background: var(--surface-2); } + +.inv-table__td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-dim); + color: var(--text); + vertical-align: middle; +} + +.inv-table__td--muted { color: var(--text-dim); } +.inv-table__td--right { text-align: right; } + +/* ============================================================ + LINKS + ============================================================ */ +.link { color: var(--cyan); text-decoration: none; transition: color var(--t); } +.link:hover { color: #fff; } +.link--sm { font-size: var(--font-size-sm); }