diff --git a/app.exe b/app.exe
index 8e0d721..d3dc044 100644
Binary files a/app.exe and b/app.exe differ
diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go
index 966cb38..ac0f1a6 100644
--- a/backend/cmd/app/main.go
+++ b/backend/cmd/app/main.go
@@ -7,7 +7,6 @@ import (
"log"
"net/http"
"os"
- "strings"
"unicard-go/backend/internal/admin"
authentication "unicard-go/backend/internal/auth"
"unicard-go/backend/internal/user"
@@ -88,7 +87,21 @@ func main() {
mux.HandleFunc("POST /v1/forgot-password/send-otp", authHandler.ForgotPasswordSendOTP)
mux.HandleFunc("POST /v1/forgot-password/verify-otp", authHandler.ForgotPasswordVerifyOTP)
mux.HandleFunc("POST /v1/reset-password", authHandler.ResetPassword)
- mux.HandleFunc("GET /{username}", userHandler.DashboardView)
+ mux.HandleFunc("GET /u/{username}", userHandler.ProfileView)
+ mux.HandleFunc("GET /u/{username}/dashboard", userHandler.DashboardView)
+ mux.HandleFunc("GET /u/{username}/card", userHandler.CardView)
+ mux.HandleFunc("GET /u/{username}/topup", userHandler.TopUpView)
+ // Your frontend calls this to get the Stripe URL
+ mux.HandleFunc("POST /api/topup/create-session/{username}", userHandler.CreateStripeCheckoutSession)
+
+ // Payment gateway endpoints
+ // STRIPE'S servers call this behind the scenes when the payment is done
+ mux.HandleFunc("POST /api/webhooks/stripe", userHandler.StripeWebhook)
+ mux.HandleFunc("POST /v1/user/{username}/topup/checkout", userHandler.CreateStripeCheckoutSession) //
+ //mux.HandleFunc("GET /v1/user/{username}/topup/success", userHandler.TopUpSuccessHandler)
+ mux.HandleFunc("GET /u/{username}/transaction", userHandler.TransactionView)
+ mux.HandleFunc("GET /u/{username}/transactions", userHandler.TransactionView)
+
mux.HandleFunc("GET /v1/user/{username}", userHandler.DashboardHandler)
mux.HandleFunc("GET /v1/user/{username}/transactions", userHandler.TransactionsJSONHandler)
//mux.HandleFunc("GET /logout",)
@@ -120,7 +133,7 @@ func main() {
mux.HandleFunc("POST /v1/admin/{username}/deletecardauth", adminHanlder.DeleteCardHandler)
mux.HandleFunc("GET /admin/{username}/delete-cards", adminHanlder.DeleteCardView)
- // terminal simulation endpoints
+ // terminal endpoints for Fare and Retails.
mux.HandleFunc("GET /terminal-sim", adminHanlder.TerminalSimView)
mux.HandleFunc("POST /v1/terminal-sim/transact", adminHanlder.TerminalSimTransactionHandler)
@@ -131,16 +144,6 @@ func main() {
return
}
- // Handle GET /{username}/transaction(s) manually to avoid ServeMux conflict with /assets/
- parts := strings.Split(r.URL.Path, "/")
- if len(parts) == 3 && (parts[2] == "transaction" || parts[2] == "transactions") && r.Method == http.MethodGet {
- if parts[1] != "assets" && parts[1] != "storage" && parts[1] != "v1" && parts[1] != "admin" {
- r.SetPathValue("username", parts[1])
- userHandler.TransactionView(w, r)
- return
- }
- }
-
mux.ServeHTTP(w, r)
})
diff --git a/backend/internal/admin/admin_merchant.go b/backend/internal/admin/admin_merchant.go
index 23f2561..29ceb2c 100644
--- a/backend/internal/admin/admin_merchant.go
+++ b/backend/internal/admin/admin_merchant.go
@@ -139,7 +139,7 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R
termQuery := fmt.Sprintf(`
SELECT m.merchant_id, t.terminal_id, t.terminal_sn, t.device_name, t.status
FROM terminals t
- JOIN merchants m ON t.merchant_id = m.user_id
+ JOIN merchants m ON t.merchant_id = m.merchant_id
WHERE m.merchant_id IN (%s)`, strings.Join(placeholders, ","))
termRows, err := h.DB.Query(termQuery, termArgs...)
@@ -262,7 +262,7 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request)
var businessAddress string
_ = tx.QueryRow("SELECT business_address FROM merchants WHERE merchant_id = ?", merchantID).Scan(&businessAddress)
- _, err = tx.Exec("UPDATE terminals SET merchant_id = ?, location_details = ?, status = 'active' WHERE terminal_sn = ?", merchantUserID, businessAddress, req.TerminalSn)
+ _, err = tx.Exec("UPDATE terminals SET merchant_id = ?, device_name = ?, location_details = ?, status = 'active' WHERE terminal_sn = ?", merchantID, req.DeviceName, businessAddress, req.TerminalSn)
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to assign terminal"})
return
@@ -489,7 +489,7 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request)
}
// Update terminals assigned to this merchant
- _, err = tx.Exec("UPDATE terminals SET merchant_id = NULL, location_details = '', status = 'inactive' WHERE merchant_id = ?", merchantUserID)
+ _, err = tx.Exec("UPDATE terminals SET merchant_id = NULL, location_details = '', status = 'inactive' WHERE merchant_id = ?", merchantID)
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to reset terminals"})
return
diff --git a/backend/internal/admin/terminal_sim.go b/backend/internal/admin/terminal_sim.go
index 18cfc5f..14736ff 100644
--- a/backend/internal/admin/terminal_sim.go
+++ b/backend/internal/admin/terminal_sim.go
@@ -20,7 +20,7 @@ func (h *Handler) TerminalSimView(w http.ResponseWriter, r *http.Request) {
}
var merchants []Merchant
- rows, err := h.DB.Query("SELECT user_id, business_name FROM merchants")
+ rows, err := h.DB.Query("SELECT merchant_id, business_name FROM merchants")
if err == nil {
defer rows.Close()
for rows.Next() {
@@ -121,7 +121,7 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R
// 2.5 Get merchant commission rate
var commissionRate float64
- err = h.DB.QueryRow("SELECT commission_rate FROM merchants WHERE user_id = ?", req.MerchantID).Scan(&commissionRate)
+ err = h.DB.QueryRow("SELECT commission_rate FROM merchants WHERE merchant_id = ?", req.MerchantID).Scan(&commissionRate)
if err != nil {
commissionRate = 2.00 // default fallback
}
@@ -203,8 +203,8 @@ func (h *Handler) TerminalSimTransactionHandler(w http.ResponseWriter, r *http.R
}
jsonwrite.WriteJSON(w, http.StatusOK, map[string]interface{}{
- "success": true,
- "message": "Transaction successful",
+ "success": true,
+ "message": "Transaction successful",
"service_fee": serviceFee,
})
}
diff --git a/backend/internal/auth/login.go b/backend/internal/auth/login.go
index 36f5b70..709c714 100644
--- a/backend/internal/auth/login.go
+++ b/backend/internal/auth/login.go
@@ -106,7 +106,7 @@ func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) {
}
// Determine redirect based on role
- redirectURL := "/" + userName // Default for customer
+ redirectURL := "/u/" + userName + "/dashboard" // Default for customer
switch role {
case "super_admin":
redirectURL = "/admin/" + userName // Super admin dashboard
diff --git a/backend/internal/auth/signup.go b/backend/internal/auth/signup.go
index 4f8dfa0..c879ea3 100644
--- a/backend/internal/auth/signup.go
+++ b/backend/internal/auth/signup.go
@@ -268,7 +268,7 @@ func (h *Handler) SignupHandler(w http.ResponseWriter, r *http.Request) {
Phone: req.ContactNumber,
CreatedAt: createdAt,
Balance: balance,
- Role: "Customer",
+ Role: "customer", // Lowercase to perfectly match ENUM('customer') in unicardv3.sql
Status: "active",
}
diff --git a/backend/internal/user/customer_card.go b/backend/internal/user/customer_card.go
new file mode 100644
index 0000000..93f321e
--- /dev/null
+++ b/backend/internal/user/customer_card.go
@@ -0,0 +1,19 @@
+package user
+
+import (
+ "fmt"
+ "net/http"
+)
+
+func (h *Handler) CardView(w http.ResponseWriter, r *http.Request) {
+ fmt.Println("Card view is running...")
+
+ username := r.PathValue("username")
+ data := struct {
+ Username string
+ }{
+ Username: username,
+ }
+
+ h.Tpl.ExecuteTemplate(w, "card.html", data)
+}
diff --git a/backend/internal/user/customer_profile.go b/backend/internal/user/customer_profile.go
new file mode 100644
index 0000000..d45d8f1
--- /dev/null
+++ b/backend/internal/user/customer_profile.go
@@ -0,0 +1,19 @@
+package user
+
+import (
+ "fmt"
+ "net/http"
+)
+
+func (h *Handler) ProfileView(w http.ResponseWriter, r *http.Request) {
+ fmt.Println("Profile view is running...")
+
+ username := r.PathValue("username")
+ data := struct {
+ Username string
+ }{
+ Username: username,
+ }
+
+ h.Tpl.ExecuteTemplate(w, "profile.html", data)
+}
diff --git a/backend/internal/user/customer_topup.go b/backend/internal/user/customer_topup.go
new file mode 100644
index 0000000..8d642d3
--- /dev/null
+++ b/backend/internal/user/customer_topup.go
@@ -0,0 +1,231 @@
+package user
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "time"
+ jsonwrite "unicard-go/backend/internal/pkg/handler"
+
+ "github.com/stripe/stripe-go/v85"
+ "github.com/stripe/stripe-go/v85/checkout/session"
+)
+
+type TopUpRequest struct {
+ CardNumber string `json:"card_number"`
+ Amount float64 `json:"amount"`
+}
+
+type TopUpRecord struct {
+ TopupID string `json:"topup_id" db:"topup_id"`
+ CardNumber string `json:"card_number" db:"card_number"`
+ Amount float64 `json:"amount" db:"amount"`
+ ConvenienceFee float64 `json:"convenience_fee" db:"convenience_fee"`
+ GatewayCost float64 `json:"gateway_cost" db:"gateway_cost"`
+ PaymentMethod string `json:"payment_method" db:"payment_method"`
+}
+
+func (h *Handler) TopUpView(w http.ResponseWriter, r *http.Request) {
+ fmt.Println("TopUp view is running...")
+
+ username := r.PathValue("username")
+ data := struct {
+ Username string
+ }{
+ Username: username,
+ }
+
+ h.Tpl.ExecuteTemplate(w, "customer_topup.html", data)
+}
+
+func (h *Handler) CreateStripeCheckoutSession(w http.ResponseWriter, r *http.Request) {
+ username := r.PathValue("username")
+
+ var req TopUpRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Invalid request payload",
+ })
+ return
+ }
+ if req.Amount < 50 {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Minimum topup amount is 50 PHP",
+ })
+ return
+ }
+
+ // Fetch card number and email securely from DB instead of trusting the frontend
+ var cardNumber, email string
+ err := h.DB.QueryRow(`
+ SELECT c.card_number, u.email
+ FROM cards c
+ JOIN users u ON c.user_id = u.user_id
+ WHERE u.username = ? LIMIT 1
+ `, username).Scan(&cardNumber, &email)
+
+ if err != nil {
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to find card for user",
+ })
+ return
+ }
+
+ // Stripe takes amount in cents/lowest denomination. For PHP, it's centavos.
+ topupCentavos := int64(req.Amount * 100) // ex: 100 -> 10000
+ feeCentavos := int64(15 * 100) // 15 pesos for fee
+ domain := "http://" + os.Getenv("SERVER_PORT") + os.Getenv("PORT")
+ // Fallback if domain is malformed
+ if domain == "http://" {
+ domain = "http://localhost:3000"
+ }
+ stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
+ params := &stripe.CheckoutSessionParams{
+ PaymentMethodTypes: stripe.StringSlice([]string{
+ "card",
+ }),
+ Metadata: map[string]string{
+ "card_number": cardNumber, // Use securely fetched card number from DB!
+ "base_amount": fmt.Sprintf("%.2f", req.Amount),
+ "convenience_fee": "15.00",
+ },
+ LineItems: []*stripe.CheckoutSessionLineItemParams{
+ {
+ PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
+ Currency: stripe.String("php"),
+ ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
+ Name: stripe.String("Unicard Top-Up"),
+ Description: stripe.String("Top up your unicard with stripe payment"),
+ },
+ UnitAmount: stripe.Int64(topupCentavos),
+ },
+ Quantity: stripe.Int64(1),
+ },
+ {
+ PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
+ Currency: stripe.String("php"),
+ ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
+ Name: stripe.String("Convienence Fee"),
+ Description: stripe.String("15 peso convienence fee"),
+ },
+ UnitAmount: stripe.Int64(feeCentavos),
+ },
+ Quantity: stripe.Int64(1),
+ },
+ },
+ Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
+ SuccessURL: stripe.String(domain + "/u/" + username + "/dashboard"),
+ CancelURL: stripe.String(domain + "/u/" + username + "/topup"),
+ }
+
+ // creating the checkout session
+ s, err := session.New(params)
+
+ // Handle error if session creation fails
+ if err != nil {
+ log.Println("Failed to create checkout session:", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to create checkout session",
+ })
+ return
+ }
+
+ // Handle success case
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
+ Success: true,
+ Message: "Checkout session created successfully",
+ Data: map[string]string{"url": s.URL},
+ })
+
+ log.Println("Checkout session created successfully:", s.URL)
+}
+
+// save topup tp database
+func (h *Handler) SaveTopUpToDatabase(w http.ResponseWriter, r *http.Request) {
+ log.Println("SaveTopUpToDatabase running...")
+
+ var req TopUpRecord
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Invalid request payload",
+ })
+ return
+ }
+
+ // Generate Unique IDs for both ledgers
+ topupID := fmt.Sprintf("TOPUP-%d", time.Now().UnixNano())
+ transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano())
+
+ // Start the Database Transaction
+ tx, err := h.DB.Begin()
+ if err != nil {
+ log.Println("Failed to start transaction:", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to start transaction",
+ })
+ return
+ }
+
+ // SAFETY NET: This automatically rolls back if the function exits before tx.Commit()
+ defer tx.Rollback()
+
+ // Update User Balance (cards table)
+ if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, req.Amount, req.CardNumber); err != nil {
+ log.Println("Failed to update user balance:", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to update user balance",
+ })
+ return // The defer statement will automatically handle the rollback
+ }
+
+ // Insert into Loading Ledger (top_ups table)
+ queryTopUp := `INSERT INTO top_ups (topup_id, card_number, amount, convenience_fee, gateway_cost, payment_method) VALUES (?, ?, ?, ?, ?, ?)`
+ if _, err := tx.Exec(queryTopUp, topupID, req.CardNumber, req.Amount, req.ConvenienceFee, req.GatewayCost, req.PaymentMethod); err != nil {
+ log.Println("Failed to record top-up:", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to record top-up",
+ })
+ return
+ }
+
+ // Insert into Spending Ledger (transactions table)
+ // *Note: Adjust 'category' if your enum doesn't include 'top_up'
+ queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
+ // "Stripe" as the merchant_id since this is an internal load, not a retail/Fare purchase
+ if _, err := tx.Exec(queryTx, transactionID, req.CardNumber, sql.NullString{}, sql.NullString{}, "topup", req.Amount, req.ConvenienceFee, sql.NullString{}); err != nil {
+ log.Println("Failed to record global transaction:", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to record global transaction",
+ })
+ return
+ }
+
+ // THE MOST IMPORTANT PART: Commit the transaction!
+ if err := tx.Commit(); err != nil {
+ log.Println("Failed to finalize database changes:", err)
+ jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
+ Success: false,
+ Message: "Failed to finalize database changes",
+ })
+ return
+ }
+
+ // Success Response
+ jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
+ Success: true,
+ Message: "Top-up fully processed and saved",
+ })
+ log.Println("Transaction saved successfully")
+}
diff --git a/backend/internal/user/dashboard.go b/backend/internal/user/dashboard.go
index 04913e7..5fe8f6f 100644
--- a/backend/internal/user/dashboard.go
+++ b/backend/internal/user/dashboard.go
@@ -50,6 +50,7 @@ func (h *Handler) DashboardView(w http.ResponseWriter, r *http.Request) {
h.Tpl.ExecuteTemplate(w, "dashboard.html", data)
}
+
func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
fmt.Println("Dashboard JSON handler is running...")
@@ -135,7 +136,7 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
// Fetch recent transactions
txnQuery := `
- SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id
+ SELECT t.transaction_id, t.created_at, m.business_name, t.transaction_type, t.amount, t.terminal_id, t.processed_by
FROM transactions t
JOIN cards c ON t.card_number = c.card_number
JOIN users u ON c.user_id = u.user_id
@@ -151,11 +152,14 @@ func (h *Handler) DashboardHandler(w http.ResponseWriter, r *http.Request) {
var t Transaction
var createdAt string
var businessName sql.NullString
- if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &t.TerminalID); err == nil {
+ var processedBy sql.NullString
+ if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &t.TerminalID, &processedBy); err == nil {
t.Date = formatDate(createdAt)
t.Time = formatTime(createdAt)
if businessName.Valid {
t.Description = businessName.String
+ } else if processedBy.Valid && processedBy.String == "stripe" {
+ t.Description = "Stripe Top-Up"
} else {
t.Description = "Terminal Simulation"
}
diff --git a/backend/internal/user/stripe_webhook.go b/backend/internal/user/stripe_webhook.go
new file mode 100644
index 0000000..82f98a2
--- /dev/null
+++ b/backend/internal/user/stripe_webhook.go
@@ -0,0 +1,105 @@
+package user
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/stripe/stripe-go/v85"
+ "github.com/stripe/stripe-go/v85/webhook"
+)
+
+// StripeWebhook is the endpoint Stripe will POST to when a payment succeeds
+func (h *Handler) StripeWebhook(w http.ResponseWriter, r *http.Request) {
+ const MaxBodyBytes = int64(65536)
+ r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
+
+ payload, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "Error reading request body", http.StatusServiceUnavailable)
+ return
+ }
+
+ // 1. Verify the request actually came from Stripe
+ endpointSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") // Get this from your Stripe Dashboard
+ signatureHeader := r.Header.Get("Stripe-Signature")
+
+ event, err := webhook.ConstructEventWithOptions(payload, signatureHeader, endpointSecret, webhook.ConstructEventOptions{
+ IgnoreAPIVersionMismatch: true,
+ })
+ if err != nil {
+ log.Println("Webhook signature verification failed:", err)
+ http.Error(w, "Bad signature", http.StatusBadRequest)
+ return
+ }
+
+ // 2. Only process successful checkout sessions
+ if event.Type == "checkout.session.completed" {
+ var session stripe.CheckoutSession
+ err := json.Unmarshal(event.Data.Raw, &session)
+ if err != nil {
+ log.Println("Error parsing webhook JSON:", err)
+ http.Error(w, "Error parsing webhook JSON", http.StatusBadRequest)
+ return
+ }
+
+ // 3. Extract the hidden data we passed earlier
+ cardNumber := session.Metadata["card_number"]
+ amountStr := session.Metadata["base_amount"]
+ feeStr := session.Metadata["convenience_fee"]
+
+ baseAmount, _ := strconv.ParseFloat(amountStr, 64)
+ convenienceFee, _ := strconv.ParseFloat(feeStr, 64)
+
+ // 4. Safely write to the database
+ err = h.processSuccessfulTopUp(cardNumber, baseAmount, convenienceFee)
+ if err != nil {
+ log.Println("Database transaction failed:", err)
+ http.Error(w, "Database error", http.StatusInternalServerError)
+ return
+ }
+
+ log.Printf("Successfully loaded ₱%.2f onto card %s", baseAmount, cardNumber)
+ }
+
+ // Stripe expects a 200 OK immediately to know we received the event
+ w.WriteHeader(http.StatusOK)
+}
+
+// Helper function to handle the database transaction (This replaces your old HTTP handler)
+func (h *Handler) processSuccessfulTopUp(cardNumber string, baseAmount float64, convenienceFee float64) error {
+ topupID := fmt.Sprintf("TOPUP-%d", time.Now().UnixNano())
+ transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano())
+
+ tx, err := h.DB.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Update User Balance
+ if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, baseAmount, cardNumber); err != nil {
+ return err
+ }
+
+ // Insert into Loading Ledger (top_ups table)
+ // NOTE: handled_by is nil because this is automated
+ queryTopUp := `INSERT INTO top_ups (topup_id, card_number, amount, convenience_fee, payment_method) VALUES (?, ?, ?, ?, ?)`
+ if _, err := tx.Exec(queryTopUp, topupID, cardNumber, baseAmount, convenienceFee, "stripe"); err != nil {
+ return err
+ }
+
+ // Insert into Spending Ledger (transactions table)
+ // NOTE: Ensure "SYS_STRIPE", "SYS_TERM" and "SYS_BOT" exist in your merchants, terminals and users tables to prevent Foreign Key crashes!
+ queryTx := `INSERT INTO transactions (transaction_id, card_number, transaction_type, amount, service_fee) VALUES (?, ?, ?, ?, ?)`
+ if _, err := tx.Exec(queryTx, transactionID, cardNumber, "topup", baseAmount, convenienceFee); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
diff --git a/backend/internal/user/transaction.go b/backend/internal/user/transaction.go
index 090d44f..c41a42e 100644
--- a/backend/internal/user/transaction.go
+++ b/backend/internal/user/transaction.go
@@ -40,7 +40,7 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request
FROM transactions t
JOIN cards c ON t.card_number = c.card_number
JOIN users u ON c.user_id = u.user_id
- LEFT JOIN merchants m ON t.merchant_id = m.user_id
+ LEFT JOIN merchants m ON t.merchant_id = m.merchant_id
WHERE u.username = ?
ORDER BY t.created_at DESC
`
@@ -64,16 +64,28 @@ func (h *Handler) TransactionsJSONHandler(w http.ResponseWriter, r *http.Request
var t TxnResponse
var createdAt string
var businessName sql.NullString
- if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &t.TerminalID); err == nil {
+ var terminalId sql.NullString
+ if err := rows.Scan(&t.TransactionID, &createdAt, &businessName, &t.Type, &t.Amount, &terminalId); err == nil {
t.Status = "Completed"
t.Date = formatDate(createdAt) // Uses formatDate from dashboard.go
t.Time = formatTime(createdAt) // Uses formatTime from dashboard.go
+
+ if terminalId.Valid {
+ t.TerminalID = terminalId.String
+ } else {
+ t.TerminalID = "N/A"
+ }
+
if businessName.Valid {
t.Description = businessName.String
+ } else if t.Type == "topup" {
+ t.Description = "Stripe Top-Up"
} else {
t.Description = "Transaction"
}
transactions = append(transactions, t)
+ } else {
+ fmt.Printf("Scan error: %v\n", err)
}
}
} else {
diff --git a/docs/unicardv1.sql b/docs/unicardv1.sql
index f727aa5..2c8c5db 100644
--- a/docs/unicardv1.sql
+++ b/docs/unicardv1.sql
@@ -1,3 +1,5 @@
+-- unicardv2 is deprecated. Use unicardv3.
+
-- CREATE DATABASE IF NOT EXISTS unicardv1;
-- USE unicardv2;
@@ -103,7 +105,7 @@ CREATE TABLE transactions (
card_number VARCHAR(20) NOT NULL COMMENT 'Links target token balance deduction via cards.card_number',
merchant_id varchar(50) NOT NULL COMMENT 'Identifies vendor company collecting the payment token via merchants.id',
terminal_id varchar(50) NOT NULL COMMENT 'Identifies physical ESP32 or terminal node hardware unit triggering the capture via terminals.id',
- transaction_type ENUM('payment', 'refund', 'reversal') DEFAULT 'payment' COMMENT 'Categorizes ledger records to process standard deductions or transaction void mappings cleanly',
+ transaction_type ENUM('payment', 'refund', 'reversal', 'topup') DEFAULT 'payment' COMMENT 'Categorizes ledger records to process standard deductions or transaction void mappings cleanly',
amount DECIMAL(10, 2) NOT NULL COMMENT 'Total Gross fiat amount captured from the card wallet balance tracking column',
service_fee DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Platform revenue slice collected by UniCard ecosystem engine per tap processing action',
net_merchant_payout DECIMAL(10, 2) GENERATED ALWAYS AS (amount - service_fee) STORED COMMENT 'Automatically calculated column tracking exactly how much money goes to the merchant after our platform cut',
@@ -124,7 +126,7 @@ CREATE TABLE top_ups (
gateway_cost DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Actual fee incurred from external payment providers (GCash, Maya, Bank) per transaction',
net_gateway_fee DECIMAL(10, 2) GENERATED ALWAYS AS (convenience_fee - gateway_cost) STORED COMMENT 'Automatically calculated net revenue kept by the platform after 3rd party costs',
total_charged DECIMAL(10, 2) GENERATED ALWAYS AS (amount + convenience_fee) STORED COMMENT 'Automatically calculated column representing the absolute total cash value collected from the external channel source',
- payment_method ENUM('cash', 'gcash', 'maya', 'over_the_counter') NOT NULL COMMENT 'Drives system tracking to audit cash-drawer liquid positions against programmatic API callbacks',
+ payment_method ENUM('cash', 'gcash', 'maya', 'over_the_counter', 'stripe') NOT NULL COMMENT 'Drives system tracking to audit cash-drawer liquid positions against programmatic API callbacks',
handled_by VARCHAR(50) NULL COMMENT 'Public user_id string identifier referencing the administrative staff member who manually accepted physical bills if OTC cash-loaded',
status ENUM('pending', 'completed', 'failed') DEFAULT 'completed' COMMENT 'State pipeline tracker handling payment gateway processing exceptions or drops',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated clock timestamp mapping exactly when wallet balance credits were finalized',
diff --git a/docs/unicardv3.sql b/docs/unicardv3.sql
new file mode 100644
index 0000000..1b82cc6
--- /dev/null
+++ b/docs/unicardv3.sql
@@ -0,0 +1,139 @@
+CREATE DATABASE IF NOT EXISTS unicardv3;
+USE unicardv3;
+
+-- =========================================================================
+-- CORE IDENTITY & AUTHENTICATION TABLES
+-- =========================================================================
+
+CREATE TABLE users (
+ id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'Internal row index optimized for database indexing and fast joins',
+ user_id VARCHAR(50) NOT NULL UNIQUE COMMENT 'Custom public ID (e.g., UNI-YYMM-minsecxxxx) used in APIs and frontend',
+ username VARCHAR(50) NOT NULL UNIQUE COMMENT 'Unique handle for admin/staff to log in quickly without an email',
+ name VARCHAR(100) NOT NULL COMMENT 'Full name of the individual user or client contact person',
+ email VARCHAR(100) UNIQUE NOT NULL COMMENT 'Primary email address used for consumer logins and notifications',
+ phone_number VARCHAR(20) NULL UNIQUE COMMENT 'Mobile number (e.g., +639...) for OTPs and SMS transaction alerts',
+ password_hash VARCHAR(255) NOT NULL COMMENT 'Cryptographically secured password string handled via bcrypt in Go',
+ role ENUM('super_admin', 'merchant_admin', 'merchant_staff', 'customer') NOT NULL COMMENT 'Defines application-wide role-based access control',
+ status ENUM('active', 'suspended', 'inactive') DEFAULT 'active' COMMENT 'Account access state for platform security and compliance checks',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated timestamp of account creation'
+) COMMENT='Core identity table tracking authentication and tenancy access control levels';
+
+
+CREATE TABLE system_settings (
+ setting_key VARCHAR(50) PRIMARY KEY COMMENT 'Unique string configuration key acting as the primary look-up token',
+ setting_value VARCHAR(255) NOT NULL COMMENT 'The active parameter threshold or value parsed directly by the Go backend',
+ description TEXT NULL COMMENT 'Descriptive documentation notes detailing exactly what system rules or parameters this alters',
+ updated_by VARCHAR(50) NOT NULL COMMENT 'The public users.user_id of the Super Admin who executed the latest configuration adjustment override',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated clock timestamp tracking when this specific configuration parameter was initialized',
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Automatically locks the exact clock timestamp whenever this system parameter value is updated',
+ FOREIGN KEY (updated_by) REFERENCES users(user_id)
+) COMMENT='Global platform configuration matrix driving dynamic fees, operational bounds, and system constants';
+
+
+-- =========================================================================
+-- MERCHANT TENANCY & HARDWARE REGISTRY TABLES
+-- =========================================================================
+
+CREATE TABLE merchants (
+ id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'Internal merchant row index used for fast database indexing',
+ merchant_id VARCHAR(50) NOT NULL UNIQUE COMMENT 'Custom public identifier for the business entity (e.g., MCH-2026-001)',
+ business_name VARCHAR(150) NOT NULL COMMENT 'Registered trade or company name of the client merchant',
+ business_type ENUM('retail', 'transportation', 'food_and_beverage', 'services', 'other') NOT NULL COMMENT 'Industry category for transaction filtering and analytics',
+ business_registration_number VARCHAR(100) NULL UNIQUE COMMENT 'Official government tracking number (e.g., DTI, SEC, or BIR TIN)',
+ business_address TEXT NOT NULL COMMENT 'Physical location of the main store or corporate headquarters',
+ user_id VARCHAR(50) NOT NULL COMMENT 'Links to the user_id in the users table who owns this business account',
+ owner_name VARCHAR(100) NOT NULL COMMENT 'Full name of the principal owner or authorized business representative',
+ business_email VARCHAR(100) NOT NULL UNIQUE COMMENT 'Official company contact email address for corporate updates and billing statements',
+ business_phone VARCHAR(20) NOT NULL UNIQUE COMMENT 'Official telephone or mobile number for merchant support and emergency updates',
+ commission_rate DECIMAL(5, 2) DEFAULT 2.00 COMMENT 'Percentage cut taken by UniCard per processed card transaction (e.g., 2.50 = 2.5%)',
+ settlement_account_name VARCHAR(100) NULL COMMENT 'The name on the merchant bank account or mobile wallet for payouts',
+ settlement_account_number VARCHAR(50) NULL COMMENT 'The actual bank account number or mobile number (GCash/Maya) for payouts',
+ settlement_bank_name VARCHAR(100) NULL COMMENT 'The target bank or e-wallet company name (e.g., BDO, BPI, GCash, Maya)',
+ status ENUM('pending approval', 'approved', 'rejected', 'active', 'suspended') DEFAULT 'pending approval' COMMENT 'Operational state of the merchant ecosystem tenancy',
+ dti_document VARCHAR(255) NULL COMMENT 'File path for the uploaded DTI registration document',
+ bir_document VARCHAR(255) NULL COMMENT 'File path for the uploaded BIR registration document',
+ other_document VARCHAR(255) NULL COMMENT 'File path for any other uploaded business documents',
+ approved_by VARCHAR(50) NULL COMMENT 'The user_id of the Super Admin who verified and activated this company profile',
+ approved_at TIMESTAMP NULL COMMENT 'The specific date and timestamp when the business was activated',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated date and time record of the initial registration request',
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Automatically updates whenever any merchant profile field is modified',
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT,
+ FOREIGN KEY (approved_by) REFERENCES users(user_id) ON DELETE SET NULL
+) COMMENT='Enterprise business registry tracking partner tenants, hardware mapping nodes, and financial settlement details';
+
+
+CREATE TABLE terminals (
+ id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'Internal hardware registry auto-increment row index',
+ terminal_id VARCHAR(50) NOT NULL UNIQUE COMMENT 'Custom public hardware identifier (e.g., TRM-2026-0001) used in API payloads',
+ terminal_sn VARCHAR(50) UNIQUE NOT NULL COMMENT 'Physical factory-assigned unique serial number or MAC address of the ESP32 board',
+ merchant_id VARCHAR(50) NULL COMMENT 'Links to the merchant_id of the managing merchant entity',
+ device_name VARCHAR(100) NOT NULL COMMENT 'Human-readable descriptor identifying placement (e.g., Counter 1, Jeepney Plate # ABC-123)',
+ location_details VARCHAR(255) NULL COMMENT 'Optional physical sector data, such as a branch route path or stall number designation',
+ status ENUM('active', 'suspended', 'inactive') DEFAULT 'inactive' COMMENT 'Operational network connectivity state of the edge node hardware',
+ last_heartbeat TIMESTAMP NULL COMMENT 'Tracks the precise timestamp of the last successful ping packet received from the ESP32 network stack',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated clock timestamp tracking initial edge device registration',
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Automatically monitors configuration adjustments or state transitions over time',
+ -- Fixed: was merchants(user_id), now correctly points to merchants(merchant_id)
+ FOREIGN KEY (merchant_id) REFERENCES merchants(merchant_id) ON DELETE CASCADE
+) COMMENT='Hardware node registry tracking deployed physical authentication nodes and network heartbeat states';
+
+
+-- =========================================================================
+-- UTILITY & USER TRANSACTION LOGS TABLES (HIGH GROWING DATASETS)
+-- =========================================================================
+
+CREATE TABLE cards (
+ card_number VARCHAR(20) PRIMARY KEY COMMENT 'The visible consumer-facing identifier printed on the physical plastic token',
+ card_uid VARCHAR(50) NOT NULL UNIQUE COMMENT 'The physical hardware chip unique UID read directly from the MIFARE/RFID sectors',
+ user_id VARCHAR(50) NULL COMMENT 'Links cardholder account identity via the public users.user_id string identifier',
+ card_type ENUM('regular', 'student', 'pwd', 'senior') DEFAULT 'regular' COMMENT 'Drives dynamic discount calculation algorithms across transportation fares',
+ discount_verified BOOLEAN DEFAULT FALSE COMMENT 'Flags whether regulatory documents were verified for fare discount tier eligibility',
+ balance DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Current secure stored monetary value assigned to the physical card unit',
+ loyalty_points DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Accrued transaction reward points redeemable at verified retail merchant stations',
+ status ENUM('active', 'inactive', 'blocked', 'lost') DEFAULT 'inactive' COMMENT 'Lifecycle block status constraint to instantly freeze stolen or missing tokens',
+ expiry_date DATE NOT NULL COMMENT 'Expiration threshold date determining card block lifecycle validations',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Timestamp tracking when the physical token record was registered in inventory',
+ linked_at TIMESTAMP NULL DEFAULT NULL COMMENT 'Timestamp tracking the exact moment the card was registered to a specific user',
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Automatically updates when balances adjust or card status switches occur',
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL
+) COMMENT='Ecosystem transit wallet asset tracker maintaining balances, hardware mapping tokens, and fare tier flags';
+
+
+CREATE TABLE transactions (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Internal financial primary index scaled to 64-bit headroom to comfortably support billions of platform entries',
+ transaction_id VARCHAR(50) NOT NULL UNIQUE COMMENT 'Custom unique public reference string (e.g., TXN-2026-104294) printed on digital and paper receipts',
+ card_number VARCHAR(20) NOT NULL COMMENT 'Links target token balance deduction via cards.card_number',
+ merchant_id VARCHAR(50) NULL COMMENT 'Identifies vendor company collecting the payment token via merchants.merchant_id',
+ terminal_id VARCHAR(50) NULL COMMENT 'Identifies physical ESP32 or terminal node hardware unit triggering the capture via terminals.terminal_id',
+ transaction_type ENUM('payment', 'refund', 'reversal', 'topup') DEFAULT 'payment' COMMENT 'Categorizes ledger records to process standard deductions or transaction void mappings cleanly',
+ amount DECIMAL(10, 2) NOT NULL COMMENT 'Total Gross fiat amount captured from the card wallet balance tracking column',
+ service_fee DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Platform revenue slice collected by UniCard ecosystem engine per tap processing action',
+ net_merchant_payout DECIMAL(10, 2) GENERATED ALWAYS AS (amount - service_fee) STORED COMMENT 'Automatically calculated column tracking exactly how much money goes to the merchant after our platform cut',
+ processed_by VARCHAR(50) NULL COMMENT 'Public string identifier users.user_id capturing the identity of the physical staff member operating the payment client terminal',
+ status ENUM('pending', 'completed', 'failed') DEFAULT 'completed' COMMENT 'Lifecycle state of the transaction for tracking settlement',
+ description VARCHAR(255) NULL COMMENT 'Optional human-readable note or system-generated label describing the transaction context',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Cryptographic server node timestamp securing exactly when transaction settlement clearing finalized',
+ FOREIGN KEY (card_number) REFERENCES cards(card_number),
+ -- Fixed: was merchants(user_id), now correctly points to merchants(merchant_id)
+ FOREIGN KEY (merchant_id) REFERENCES merchants(merchant_id),
+ FOREIGN KEY (terminal_id) REFERENCES terminals(terminal_id),
+ FOREIGN KEY (processed_by) REFERENCES users(user_id)
+) COMMENT='High-growth financial master ledger capturing all terminal token taps, transaction classifications, and system fees';
+
+CREATE TABLE top_ups (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Internal tracking row index scaled to 64-bit headroom to safely accommodate massive historical logging growth',
+ topup_id VARCHAR(50) NOT NULL UNIQUE COMMENT 'Custom unique public transaction code (e.g., LD-2026-987153) used for user receipts and payment gateway queries',
+ card_number VARCHAR(20) NOT NULL COMMENT 'Links target token balance injection via cards.card_number',
+ amount DECIMAL(10, 2) NOT NULL COMMENT 'Gross load amount requested by the customer before convenience charges are applied',
+ convenience_fee DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Ecosystem engine collection fee applied to over-the-air channels like GCash or Maya webhooks',
+ gateway_cost DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Actual fee incurred from external payment providers (GCash, Maya, Bank) per transaction',
+ net_gateway_fee DECIMAL(10, 2) GENERATED ALWAYS AS (convenience_fee - gateway_cost) STORED COMMENT 'Automatically calculated net revenue kept by the platform after 3rd party costs',
+ total_charged DECIMAL(10, 2) GENERATED ALWAYS AS (amount + convenience_fee) STORED COMMENT 'Automatically calculated column representing the absolute total cash value collected from the external channel source',
+ payment_method ENUM('cash', 'gcash', 'maya', 'over_the_counter', 'stripe') NOT NULL COMMENT 'Drives system tracking to audit cash-drawer liquid positions against programmatic API callbacks',
+ handled_by VARCHAR(50) NULL COMMENT 'Public user_id string identifier referencing the administrative staff member who manually accepted physical bills if OTC cash-loaded',
+ status ENUM('pending', 'completed', 'failed') DEFAULT 'completed' COMMENT 'State pipeline tracker handling payment gateway processing exceptions or drops',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated clock timestamp mapping exactly when wallet balance credits were finalized',
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Tracks chronological life cycle changes, such as a top-up shifting from pending to completed',
+ FOREIGN KEY (card_number) REFERENCES cards(card_number),
+ FOREIGN KEY (handled_by) REFERENCES users(user_id)
+) COMMENT='High-growth balance loader ledger maintaining immutable compliance auditing for all incoming ecosystem liquidity channels';
\ No newline at end of file
diff --git a/frontend/assets/js/card.js b/frontend/assets/js/customer_card.js
similarity index 82%
rename from frontend/assets/js/card.js
rename to frontend/assets/js/customer_card.js
index acb2358..c7bbb6e 100644
--- a/frontend/assets/js/card.js
+++ b/frontend/assets/js/customer_card.js
@@ -41,7 +41,55 @@ document.addEventListener("DOMContentLoaded", function () {
// Stop the script if critical elements are missing
if (!reportElementsExist || !replacementElementsExist || !cardStatusBadge) {
- return;
+ // We still want to run the toggle lock logic, so don't return here entirely if we're just ignoring old missing elements
+ console.warn("Some card management elements are missing, but we'll continue for lock toggle.");
+ }
+
+ // --- Lock Card Toggle Logic ---
+ const toggle = document.getElementById("lock-card-toggle");
+ const knob = document.getElementById("lock-card-knob");
+ const overlay = document.getElementById("card-lock-overlay");
+ let isLocked = false;
+
+ if (toggle) {
+ toggle.addEventListener("click", () => {
+ isLocked = !isLocked;
+ if (isLocked) {
+ // Switch on
+ toggle.classList.remove("bg-gray-200");
+ toggle.classList.add("bg-blue-600");
+ if (knob) {
+ knob.classList.remove("translate-x-1");
+ knob.classList.add("translate-x-6");
+ }
+
+ // Show overlay
+ if (overlay) {
+ overlay.classList.remove("hidden");
+ setTimeout(() => {
+ overlay.classList.remove("opacity-0");
+ overlay.classList.add("opacity-100");
+ }, 10);
+ }
+ } else {
+ // Switch off
+ toggle.classList.remove("bg-blue-600");
+ toggle.classList.add("bg-gray-200");
+ if (knob) {
+ knob.classList.remove("translate-x-6");
+ knob.classList.add("translate-x-1");
+ }
+
+ // Hide overlay
+ if (overlay) {
+ overlay.classList.remove("opacity-100");
+ overlay.classList.add("opacity-0");
+ setTimeout(() => {
+ overlay.classList.add("hidden");
+ }, 300);
+ }
+ }
+ });
}
// --- Generic Modal Logic ---
diff --git a/frontend/assets/js/customer_topup.js b/frontend/assets/js/customer_topup.js
new file mode 100644
index 0000000..826e767
--- /dev/null
+++ b/frontend/assets/js/customer_topup.js
@@ -0,0 +1,134 @@
+document.addEventListener("DOMContentLoaded", function () {
+ const quickBtns = document.querySelectorAll('.quick-amount-btn');
+ const amountInput = document.getElementById('amount');
+
+ if (quickBtns && amountInput) {
+ quickBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ amountInput.value = btn.innerText;
+ });
+ });
+ }
+
+ const form = document.getElementById('topup-form');
+ const submitBtn = document.getElementById('topup-submit-btn');
+ const errorDiv = document.getElementById('amount-error');
+
+ // Breakdown elements
+ const breakdownContainer = document.getElementById('breakdown-container');
+ const breakdownAmount = document.getElementById('breakdown-amount');
+ const breakdownTotal = document.getElementById('breakdown-total');
+
+ // Function to run validation
+ const validateForm = () => {
+ const amountText = amountInput.value.trim();
+ const amount = parseFloat(amountText);
+ const method = document.querySelector('input[name="payment_method"]:checked');
+
+ let isValid = true;
+
+ if (amountText === '') {
+ isValid = false;
+ if (errorDiv) errorDiv.classList.add('hidden');
+ } else if (amountText.startsWith('-')) {
+ isValid = false;
+ if (errorDiv) {
+ errorDiv.textContent = 'Negative values are not allowed.';
+ errorDiv.classList.remove('hidden');
+ }
+ } else if (isNaN(amount) || amount < 50) {
+ isValid = false;
+ if (errorDiv) {
+ errorDiv.textContent = 'Minimum top-up amount is ₱50.00.';
+ errorDiv.classList.remove('hidden');
+ }
+ } else {
+ if (errorDiv) errorDiv.classList.add('hidden');
+ }
+
+ // Update breakdown
+ if (isValid) {
+ breakdownAmount.textContent = amount.toFixed(2);
+ breakdownTotal.textContent = (amount + 15).toFixed(2);
+ breakdownContainer.classList.remove('hidden');
+ } else {
+ breakdownContainer.classList.add('hidden');
+ }
+
+ submitBtn.disabled = !(isValid && method);
+ };
+
+ if (form && submitBtn) {
+ form.addEventListener('input', validateForm);
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const amountText = amountInput.value.trim();
+ const amount = parseFloat(amountText);
+ const method = document.querySelector('input[name="payment_method"]:checked');
+
+ if (!method || method.value !== 'stripe' || isNaN(amount) || amount < 50) {
+ return;
+ }
+
+ // Get username from URL path: /u/{username}/topup
+ const pathParts = window.location.pathname.split('/');
+ let username = '';
+ if (pathParts.length >= 3 && pathParts[1] === 'u') {
+ username = pathParts[2];
+ }
+
+ if (!username) {
+ if (errorDiv) {
+ errorDiv.textContent = 'Could not determine user profile from URL.';
+ errorDiv.classList.remove('hidden');
+ }
+ return;
+ }
+
+ try {
+ submitBtn.disabled = true;
+ submitBtn.innerText = 'Processing...';
+
+ const response = await fetch(`/v1/user/${username}/topup/checkout`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ amount })
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(errorText || 'Failed to initialize checkout');
+ }
+
+ const data = await response.json();
+ if (data.url) {
+ window.location.href = data.url;
+ } else {
+ throw new Error('No checkout URL returned');
+ }
+ } catch (err) {
+ console.error('Checkout error:', err);
+ if (errorDiv) {
+ errorDiv.textContent = err.message;
+ errorDiv.classList.remove('hidden');
+ }
+ submitBtn.disabled = false;
+ submitBtn.innerText = 'Top Up Now';
+ }
+ });
+ }
+
+ // Also attach to quick amount buttons so clicking them triggers validation
+ if (quickBtns && amountInput) {
+ quickBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ amountInput.value = btn.innerText;
+ validateForm();
+ });
+ });
+ }
+});
diff --git a/frontend/assets/js/dashboard.js b/frontend/assets/js/dashboard.js
index ce382c2..e348c2c 100644
--- a/frontend/assets/js/dashboard.js
+++ b/frontend/assets/js/dashboard.js
@@ -148,7 +148,9 @@ document.addEventListener("DOMContentLoaded", function () {
function fetchDashboardData() {
const pathSegments = window.location.pathname.split('/');
let userId = null;
- if (pathSegments.length >= 2 && pathSegments[1] !== '') {
+ if (pathSegments.length >= 3 && pathSegments[1] === 'u') {
+ userId = pathSegments[2];
+ } else if (pathSegments.length >= 2 && pathSegments[1] !== '') {
userId = pathSegments[1];
} else {
// fallback if it's still somehow in query string
diff --git a/frontend/assets/js/signup.js b/frontend/assets/js/signup.js
index 5ef2f72..b7980dd 100644
--- a/frontend/assets/js/signup.js
+++ b/frontend/assets/js/signup.js
@@ -159,11 +159,17 @@ document.addEventListener("DOMContentLoaded", function () {
const isLengthValid = password.length >= 8;
const isMatchValid = password === confirmPassword && confirmPassword !== '';
- const lengthCheck = document.getElementById('length-check').querySelector('.icon');
- const matchCheck = document.getElementById('match-check').querySelector('.icon');
+ const lengthCheck = document.getElementById('length-check');
+ const matchCheck = document.getElementById('match-check');
- lengthCheck.className = `icon fas ${isLengthValid ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500'}`;
- matchCheck.className = `icon fas ${isMatchValid ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500'}`;
+ const successSvg = ``;
+ const errorSvg = ``;
+
+ lengthCheck.querySelector('svg').outerHTML = isLengthValid ? successSvg : errorSvg;
+ matchCheck.querySelector('svg').outerHTML = isMatchValid ? successSvg : errorSvg;
+
+ lengthCheck.className = `flex items-center space-x-2 text-sm ${isLengthValid ? 'text-green-600' : 'text-gray-600'}`;
+ matchCheck.className = `flex items-center space-x-2 text-sm ${isMatchValid ? 'text-green-600' : 'text-gray-600'}`;
return isLengthValid && isMatchValid;
}
diff --git a/frontend/assets/js/topup.js b/frontend/assets/js/topup.js
deleted file mode 100644
index a3c67f6..0000000
--- a/frontend/assets/js/topup.js
+++ /dev/null
@@ -1,28 +0,0 @@
-document.addEventListener("DOMContentLoaded", function () {
- const quickBtns = document.querySelectorAll('.quick-amount-btn');
- const amountInput = document.getElementById('amount');
-
- if (quickBtns && amountInput) {
- quickBtns.forEach(btn => {
- btn.addEventListener('click', () => {
- amountInput.value = btn.innerText;
- });
- });
- }
-
- const form = document.getElementById('topup-form');
- const submitBtn = document.getElementById('topup-submit-btn');
-
- if (form && submitBtn) {
- form.addEventListener('input', () => {
- const amount = parseFloat(amountInput.value);
- const method = document.querySelector('input[name="payment_method"]:checked');
-
- if (amount > 0 && method) {
- submitBtn.disabled = false;
- } else {
- submitBtn.disabled = true;
- }
- });
- }
-});
diff --git a/frontend/assets/js/transaction.js b/frontend/assets/js/transaction.js
index 8b4e88e..a1505a3 100644
--- a/frontend/assets/js/transaction.js
+++ b/frontend/assets/js/transaction.js
@@ -27,7 +27,9 @@ document.addEventListener("DOMContentLoaded", function () {
function loadTransactions() {
const pathSegments = window.location.pathname.split('/');
let userId = null;
- if (pathSegments.length >= 2 && pathSegments[1] !== '') {
+ if (pathSegments.length >= 3 && pathSegments[1] === 'u') {
+ userId = pathSegments[2];
+ } else if (pathSegments.length >= 2 && pathSegments[1] !== '') {
userId = pathSegments[1];
}
diff --git a/frontend/templates/customer/card.html b/frontend/templates/customer/card.html
index 8531824..6969bcc 100644
--- a/frontend/templates/customer/card.html
+++ b/frontend/templates/customer/card.html
@@ -9,284 +9,182 @@
-
-
-
+
-
-