diff --git a/.gitignore b/.gitignore index afe833e..70b6e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,15 @@ .env .unknown -# gitgore the compiled file +# gitgore the compiled file and *.exe +*.txt # gitgore the storage directory storage/* !storage/.gitkeep storage/**/* -!storage/**/.gitkeep \ No newline at end of file +!storage/**/.gitkeep + +tested/* +tested/**/* \ No newline at end of file diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index a52703e..b50c01a 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -10,14 +10,17 @@ import ( "unicard-go/backend/internal/admin" authentication "unicard-go/backend/internal/auth" "unicard-go/backend/internal/merchant" + "unicard-go/backend/internal/middleware" "unicard-go/backend/internal/user" _ "github.com/go-sql-driver/mysql" "github.com/joho/godotenv" ) -var tpl *template.Template -var db *sql.DB +var ( + tpl *template.Template + db *sql.DB +) func main() { // Load .env file @@ -76,7 +79,8 @@ func main() { // general endpoints mux.HandleFunc("GET /login", authHandler.LoginView) - mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint + mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint + mux.HandleFunc("POST /v1/refresh", authHandler.RefreshTokenHandler) // Refresh token endpoint mux.HandleFunc("GET /merchant-signup", authHandler.MerchantSignupView) mux.HandleFunc("POST /v1/merchant-signup", authHandler.MerchantSignupHandler) mux.HandleFunc("GET /admin-signup", authHandler.AdminSignupView) @@ -91,73 +95,81 @@ 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 /u/{username}", userHandler.ProfileView) - mux.HandleFunc("PATCH /u/{username}/profile/edit", userHandler.ProfileEdit) - mux.HandleFunc("GET /v1/verify-email", userHandler.VerifyEmail) - mux.HandleFunc("POST /v1/user/{username}/profile/verify-password", userHandler.ProfileVerifyPassword) - mux.HandleFunc("PUT /u/{username}/profile/password", userHandler.ProfileChangePassword) - mux.HandleFunc("GET /u/{username}/dashboard", userHandler.DashboardView) - mux.HandleFunc("GET /u/{username}/card", userHandler.CardView) - mux.HandleFunc("POST /v1/user/{username}/card/status", userHandler.UpdateCardStatus) - mux.HandleFunc("GET /u/{username}/settings", userHandler.SettingsView) - mux.HandleFunc("GET /u/{username}/topup", userHandler.TopUpView) + // Middleware definitions + requireCustomer := middleware.RequireAuth("customer") + requireMerchant := middleware.RequireAuth("merchant_admin", "merchant_staff") + requireAdmin := middleware.RequireAuth("super_admin") + + // Customer Routes + mux.Handle("GET /u/{username}", requireCustomer(http.HandlerFunc(userHandler.ProfileView))) + mux.Handle("PATCH /u/{username}/profile/edit", requireCustomer(http.HandlerFunc(userHandler.ProfileEdit))) + mux.Handle("POST /v1/user/{username}/profile/verify-password", requireCustomer(http.HandlerFunc(userHandler.ProfileVerifyPassword))) + mux.Handle("PUT /u/{username}/profile/password", requireCustomer(http.HandlerFunc(userHandler.ProfileChangePassword))) + mux.Handle("GET /u/{username}/dashboard", requireCustomer(http.HandlerFunc(userHandler.DashboardView))) + mux.Handle("GET /u/{username}/card", requireCustomer(http.HandlerFunc(userHandler.CardView))) + mux.Handle("POST /v1/user/{username}/card/status", requireCustomer(http.HandlerFunc(userHandler.UpdateCardStatus))) + mux.Handle("GET /u/{username}/settings", requireCustomer(http.HandlerFunc(userHandler.SettingsView))) + mux.Handle("GET /u/{username}/topup", requireCustomer(http.HandlerFunc(userHandler.TopUpView))) // Your frontend calls this to get the Xendit URL - mux.HandleFunc("POST /api/topup/create-session/{username}", userHandler.CreateXenditInvoice) + mux.Handle("POST /api/topup/create-session/{username}", requireCustomer(http.HandlerFunc(userHandler.CreateXenditInvoice))) // Payment gateway endpoints // Xendit's servers call this behind the scenes when the payment is done - mux.HandleFunc("POST /api/webhooks/xendit", userHandler.XenditWebhook) - mux.HandleFunc("POST /v1/user/{username}/topup/checkout", userHandler.CreateXenditInvoice) // - //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("POST /api/webhooks/xendit/invoice", userHandler.XenditWebhook) + mux.HandleFunc("POST /api/webhooks/xendit/disbursement", merchantHandler.XenditDisbursementWebhook) + mux.Handle("POST /v1/user/{username}/topup/checkout", requireCustomer(http.HandlerFunc(userHandler.CreateXenditInvoice))) + mux.Handle("GET /u/{username}/transaction", requireCustomer(http.HandlerFunc(userHandler.TransactionView))) + mux.Handle("GET /u/{username}/transactions", requireCustomer(http.HandlerFunc(userHandler.TransactionView))) - mux.HandleFunc("GET /v1/user/{username}", userHandler.DashboardHandler) - mux.HandleFunc("GET /v1/user/{username}/transactions", userHandler.TransactionsJSONHandler) - //mux.HandleFunc("GET /logout",) + mux.Handle("GET /v1/user/{username}", requireCustomer(http.HandlerFunc(userHandler.DashboardHandler))) + mux.Handle("GET /v1/user/{username}/transactions", requireCustomer(http.HandlerFunc(userHandler.TransactionsJSONHandler))) + mux.HandleFunc("GET /logout", authHandler.LogoutHandler) + mux.HandleFunc("POST /logout", authHandler.LogoutHandler) // Allow POST as well just in cases // merchant endpoints - mux.HandleFunc("GET /merchant/{username}/dashboard", merchantHandler.MerchantDashboardView) - mux.HandleFunc("GET /v1/merchant/{username}/dashboard", merchantHandler.MerchantDashboardDataHandler) - mux.HandleFunc("GET /merchant/{username}/transactions", merchantHandler.MerchantTransactionsView) - mux.HandleFunc("GET /v1/merchant/{username}/transactions", merchantHandler.TransactionHandler) - - mux.HandleFunc("GET /v1/merchant/{username}/incomes", merchantHandler.IncomeHandler) - mux.HandleFunc("GET /merchant/{username}/account", merchantHandler.MerchantAccountView) - mux.HandleFunc("GET /v1/merchant/{username}/account", merchantHandler.MerchantAccountDataHandler) - mux.HandleFunc("POST /v1/merchant/{username}/withdraw", merchantHandler.WithdrawHandler) + mux.Handle("GET /merchant/{username}/dashboard", requireMerchant(http.HandlerFunc(merchantHandler.MerchantDashboardView))) + mux.Handle("GET /v1/merchant/{username}/dashboard", requireMerchant(http.HandlerFunc(merchantHandler.MerchantDashboardDataHandler))) + mux.Handle("GET /merchant/{username}/transactions", requireMerchant(http.HandlerFunc(merchantHandler.MerchantTransactionsView))) + mux.Handle("GET /v1/merchant/{username}/transactions", requireMerchant(http.HandlerFunc(merchantHandler.TransactionHandler))) + + mux.Handle("GET /v1/merchant/{username}/incomes", requireMerchant(http.HandlerFunc(merchantHandler.IncomeHandler))) + mux.Handle("GET /merchant/{username}/account", requireMerchant(http.HandlerFunc(merchantHandler.MerchantAccountView))) + mux.Handle("GET /v1/merchant/{username}/account", requireMerchant(http.HandlerFunc(merchantHandler.MerchantAccountDataHandler))) + mux.Handle("POST /v1/merchant/{username}/update-bank", requireMerchant(http.HandlerFunc(merchantHandler.UpdateBankDetails))) + mux.Handle("POST /v1/merchant/{username}/upload-document", requireMerchant(http.HandlerFunc(merchantHandler.UploadDocument))) + mux.Handle("POST /v1/merchant/{username}/withdraw", requireMerchant(http.HandlerFunc(merchantHandler.WithdrawHandler))) // super admin endpoints - mux.HandleFunc("GET /admin/{username}", adminHanlder.AdminDashboardView) - mux.HandleFunc("GET /v1/admin/{username}/dashboard-data", adminHanlder.AdminDashboardDataHandler) - mux.HandleFunc("GET /admin/{username}/merchants", adminHanlder.MerchantManagementView) - mux.HandleFunc("GET /v1/admin/{username}/merchants-data", adminHanlder.MerchantManagementDataHandler) - mux.HandleFunc("GET /admin/{username}/terminals", adminHanlder.TerminalRegistryView) - mux.HandleFunc("GET /v1/admin/{username}/terminals-data", adminHanlder.TerminalRegistryDataHandler) - mux.HandleFunc("GET /v1/admin/{username}/terminals/unassigned", adminHanlder.GetUnassignedTerminalsHandler) - mux.HandleFunc("POST /v1/admin/{username}/terminals/add", adminHanlder.AddTerminalHandler) - mux.HandleFunc("GET /admin/{username}/settings", adminHanlder.SystemSettingsView) - mux.HandleFunc("GET /admin/{username}/transactions", adminHanlder.TransactionsView) - mux.HandleFunc("GET /v1/admin/{username}/transactions", adminHanlder.AllTransactionsJSONHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/add", adminHanlder.AddMerchantHandler) - mux.HandleFunc("GET /admin/{username}/merchants/{id}", adminHanlder.MerchantInfoView) - mux.HandleFunc("GET /v1/admin/{username}/merchants/{id}/data", adminHanlder.MerchantInfoDataHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/approve", adminHanlder.ApproveMerchantHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/reject", adminHanlder.RejectMerchantHandler) - mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/suspend", adminHanlder.SuspendMerchantHandler) - mux.HandleFunc("DELETE /v1/admin/{username}/merchants/{id}/delete", adminHanlder.DeleteMerchantHandler) - mux.HandleFunc("GET /admin/{username}/card-inventory", adminHanlder.CardInventoryView) - mux.HandleFunc("GET /v1/admin/{username}/card-inventory-data", adminHanlder.CardInventoryDataHandler) - mux.HandleFunc("POST /v1/admin/{username}/cards/{id}/block", adminHanlder.BlockCardHandler) - mux.HandleFunc("GET /admin/{username}/addcard", adminHanlder.AddCardsView) - mux.HandleFunc("GET /admin/{username}/deactivatecard", adminHanlder.DeactivateView) - mux.HandleFunc("POST /v1/admin/{username}/addcardauth", adminHanlder.AddCardHandler) - mux.HandleFunc("POST /v1/admin/{username}/deactivatecardauth", adminHanlder.DeactivateCardHanlder) - mux.HandleFunc("POST /v1/admin/{username}/deletecardauth", adminHanlder.DeleteCardHandler) - mux.HandleFunc("GET /admin/{username}/delete-cards", adminHanlder.DeleteCardView) + mux.Handle("GET /admin/{username}", requireAdmin(http.HandlerFunc(adminHanlder.AdminDashboardView))) + mux.Handle("GET /v1/admin/{username}/dashboard-data", requireAdmin(http.HandlerFunc(adminHanlder.AdminDashboardDataHandler))) + mux.Handle("GET /admin/{username}/merchants", requireAdmin(http.HandlerFunc(adminHanlder.MerchantManagementView))) + mux.Handle("GET /v1/admin/{username}/merchants-data", requireAdmin(http.HandlerFunc(adminHanlder.MerchantManagementDataHandler))) + mux.Handle("GET /admin/{username}/terminals", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRegistryView))) + mux.Handle("GET /v1/admin/{username}/terminals-data", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRegistryDataHandler))) + mux.Handle("GET /v1/admin/{username}/terminals/unassigned", requireAdmin(http.HandlerFunc(adminHanlder.GetUnassignedTerminalsHandler))) + mux.Handle("POST /v1/admin/{username}/terminals/add", requireAdmin(http.HandlerFunc(adminHanlder.AddTerminalHandler))) + mux.Handle("GET /admin/{username}/settings", requireAdmin(http.HandlerFunc(adminHanlder.SystemSettingsView))) + mux.Handle("GET /admin/{username}/transactions", requireAdmin(http.HandlerFunc(adminHanlder.TransactionsView))) + mux.Handle("GET /v1/admin/{username}/transactions", requireAdmin(http.HandlerFunc(adminHanlder.AllTransactionsJSONHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/add", requireAdmin(http.HandlerFunc(adminHanlder.AddMerchantHandler))) + mux.Handle("GET /admin/{username}/merchants/{id}", requireAdmin(http.HandlerFunc(adminHanlder.MerchantInfoView))) + mux.Handle("GET /v1/admin/{username}/merchants/{id}/data", requireAdmin(http.HandlerFunc(adminHanlder.MerchantInfoDataHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/{id}/approve", requireAdmin(http.HandlerFunc(adminHanlder.ApproveMerchantHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/{id}/reject", requireAdmin(http.HandlerFunc(adminHanlder.RejectMerchantHandler))) + mux.Handle("POST /v1/admin/{username}/merchants/{id}/suspend", requireAdmin(http.HandlerFunc(adminHanlder.SuspendMerchantHandler))) + mux.Handle("DELETE /v1/admin/{username}/merchants/{id}/delete", requireAdmin(http.HandlerFunc(adminHanlder.DeleteMerchantHandler))) + mux.Handle("GET /admin/{username}/card-inventory", requireAdmin(http.HandlerFunc(adminHanlder.CardInventoryView))) + mux.Handle("GET /v1/admin/{username}/card-inventory-data", requireAdmin(http.HandlerFunc(adminHanlder.CardInventoryDataHandler))) + mux.Handle("POST /v1/admin/{username}/cards/{id}/block", requireAdmin(http.HandlerFunc(adminHanlder.BlockCardHandler))) + mux.Handle("GET /admin/{username}/addcard", requireAdmin(http.HandlerFunc(adminHanlder.AddCardsView))) + mux.Handle("GET /admin/{username}/deactivatecard", requireAdmin(http.HandlerFunc(adminHanlder.DeactivateView))) + mux.Handle("POST /v1/admin/{username}/addcardauth", requireAdmin(http.HandlerFunc(adminHanlder.AddCardHandler))) + mux.Handle("POST /v1/admin/{username}/deactivatecardauth", requireAdmin(http.HandlerFunc(adminHanlder.DeactivateCardHanlder))) + mux.Handle("POST /v1/admin/{username}/deletecardauth", requireAdmin(http.HandlerFunc(adminHanlder.DeleteCardHandler))) + mux.Handle("GET /admin/{username}/delete-cards", requireAdmin(http.HandlerFunc(adminHanlder.DeleteCardView))) // terminal endpoints for Fare and Retails. - mux.HandleFunc("GET /terminal-sim", adminHanlder.TerminalSimView) + mux.HandleFunc("GET /terminal-sim", adminHanlder.TerminalSimView) // kept public since it's a sim mux.HandleFunc("POST /v1/terminal-sim/transact", adminHanlder.TerminalSimTransactionHandler) // Wrap mux with custom handler for root redirect @@ -171,8 +183,8 @@ func main() { }) // Start Server - fmt.Println("Server started on: http://" + serverAddress + port) - if err := http.ListenAndServe(serverAddress+port, customHandler); err != nil { + fmt.Println("Server started on: http://" + serverAddress + ":" + port) + if err := http.ListenAndServe(serverAddress+":"+port, customHandler); err != nil { log.Fatal(err) } } diff --git a/backend/internal/admin/admin_merchant.go b/backend/internal/admin/admin_merchant.go index 29ceb2c..ec1761f 100644 --- a/backend/internal/admin/admin_merchant.go +++ b/backend/internal/admin/admin_merchant.go @@ -9,6 +9,7 @@ import ( "os" "strconv" "strings" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" smtp "unicard-go/backend/internal/pkg/smtpbody" structs "unicard-go/backend/internal/pkg/structs" @@ -190,9 +191,9 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R } type ApproveMerchantRequest struct { - CommissionRate string `json:"commissionRate" validate:"required"` - TerminalSn string `json:"terminalSn" validate:"required"` - DeviceName string `json:"deviceName"` + CommissionRate string `json:"commissionRate" validate:"required"` + TerminalSn string `json:"terminalSn" validate:"required"` + DeviceName string `json:"deviceName"` } func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) { @@ -226,9 +227,9 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) return } - // Get merchant user_id, email, and owner_name for the notification email - var merchantUserID, merchantEmail, ownerName string - err = tx.QueryRow("SELECT user_id, business_email, owner_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName) + // Get merchant user_id, email, owner_name, and business_name for the notification email and welcome tx + var merchantUserID, merchantEmail, ownerName, businessName string + err = tx.QueryRow("SELECT user_id, business_email, owner_name, business_name FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &merchantEmail, &ownerName, &businessName) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) return @@ -239,6 +240,8 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) _, err = tx.Exec(` UPDATE merchants SET status = 'active', + document_status = 'approved', + message = 'Congratulations! Your UniCard Merchant Account is now fully active.', commission_rate = 2.00, approved_by = ?, approved_at = CURRENT_TIMESTAMP @@ -268,6 +271,24 @@ func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) return } + // Format business name safely for transaction ID (remove spaces, uppercase) + /*safeBusinessName := strings.ToUpper(strings.ReplaceAll(businessName, " ", "")) + if len(safeBusinessName) > 15 { + safeBusinessName = safeBusinessName[:15] + }*/ + + // Insert welcome transaction + welcomeTxnID := fmt.Sprintf("Welcome %s-%d", businessName, time.Now().UnixMilli()) + _, err = tx.Exec(` + INSERT INTO transactions + (transaction_id, merchant_id, transaction_type, amount, points_earned, service_fee, status, description) + VALUES (?, ?, 'payment', NULL, NULL, NULL, 'completed', 'Welcome to UniCard! Your merchant account is now approved and ready to accept transactions.')`, + welcomeTxnID, merchantID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to create welcome transaction"}) + return + } + if err := tx.Commit(); err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize approval"}) return @@ -338,7 +359,7 @@ func (h *Handler) RejectMerchantHandler(w http.ResponseWriter, r *http.Request) return } - _, err = tx.Exec("UPDATE merchants SET status = 'rejected' WHERE merchant_id = ?", merchantID) + _, err = tx.Exec("UPDATE merchants SET status = 'rejected', document_status = 'rejected', message = ? WHERE merchant_id = ?", req.Reason, merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to reject merchant"}) return @@ -419,7 +440,7 @@ func (h *Handler) SuspendMerchantHandler(w http.ResponseWriter, r *http.Request) return } - _, err = tx.Exec("UPDATE merchants SET status = 'suspended' WHERE merchant_id = ?", merchantID) + _, err = tx.Exec("UPDATE merchants SET status = 'suspended', message = ? WHERE merchant_id = ?", req.Reason, merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to suspend merchant"}) return @@ -481,8 +502,8 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) } defer tx.Rollback() - var merchantUserID string - err = tx.QueryRow("SELECT user_id FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID) + var merchantUserID, ownerName, merchantEmail string + err = tx.QueryRow("SELECT user_id, owner_name, business_email FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID, &ownerName, &merchantEmail) if err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) return @@ -495,15 +516,15 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) return } - // Delete from merchants - _, err = tx.Exec("DELETE FROM merchants WHERE merchant_id = ?", merchantID) + // Soft delete from merchants: Set status to 'deleted' and update message + _, err = tx.Exec("UPDATE merchants SET status = 'deleted', message = 'Your merchant account has been permanently deleted.' WHERE merchant_id = ?", merchantID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to delete merchant"}) return } - // Delete from users - _, err = tx.Exec("DELETE FROM users WHERE user_id = ?", merchantUserID) + // Soft delete from users: Set status to 'inactive' + _, err = tx.Exec("UPDATE users SET status = 'inactive' WHERE user_id = ?", merchantUserID) if err != nil { jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to delete user"}) return @@ -514,28 +535,59 @@ func (h *Handler) DeleteMerchantHandler(w http.ResponseWriter, r *http.Request) return } + // Send deletion email to merchant + go func(email, name string) { + smtpHost := os.Getenv("SMTP_HOST") + smtpPort := 587 + smtpEmail := os.Getenv("SMTP_EMAIL") + smtpSender := os.Getenv("SMTP_SENDER") + smtpPass := os.Getenv("SMTP_PASSWORD") + if smtpHost == "" || smtpEmail == "" { + log.Println("SMTP credentials not configured, skipping deletion email") + return + } + + m := gomail.NewMessage() + m.SetHeader("From", fmt.Sprintf("%s <%s>", smtpSender, smtpEmail)) + m.SetHeader("To", email) + m.SetHeader("Subject", "Unicard Account Deleted") + + htmlBody := fmt.Sprintf(smtp.MerchantDeletedEmail(), name) + m.SetBody("text/html", htmlBody) + + d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass) + if err := d.DialAndSend(m); err != nil { + log.Printf("Failed to send deletion email to %s: %v", email, err) + } else { + log.Printf("Deletion email sent successfully to %s", email) + } + }(merchantEmail, ownerName) + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant deleted successfully"}) } type MerchantDetailsData struct { - MerchantID string - UserID string - BusinessName string - BusinessType string - RegistrationNum string - BusinessAddress string - OwnerName string - BusinessEmail string - BusinessPhone string - Status string - CommissionRate float64 - SettlementBank string - SettlementName string - SettlementAcct string - CreatedAt string - DtiDocument string - BirDocument string - OtherDocument string + MerchantID string + UserID string + BusinessName string + BusinessType string + RegistrationNum string + BusinessAddress string + City string + PostalCode string + OwnerName string + BusinessEmail string + BusinessPhone string + Status string + CommissionRate float64 + SettlementBank string + SettlementName string + SettlementAcct string + CreatedAt string + BusinessDocument string + BirDocument string + OtherDocument string + DocumentStatus string } type MerchantInfoViewData struct { @@ -570,19 +622,19 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request var m MerchantDetailsData var commRate sql.NullFloat64 - var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc sql.NullString + var setBank, setName, setAcct, regNum, dtiDoc, birDoc, otherDoc, city, postal, docStatus sql.NullString err := h.DB.QueryRow(` SELECT merchant_id, user_id, business_name, business_type, business_registration_number, - business_address, owner_name, business_email, business_phone, status, + business_address, city, postal_code, owner_name, business_email, business_phone, status, commission_rate, settlement_bank_name, settlement_account_name, settlement_account_number, created_at, - dti_document, bir_document, other_document + business_document, bir_document, other_document, document_status FROM merchants WHERE merchant_id = ?`, merchantID).Scan( &m.MerchantID, &m.UserID, &m.BusinessName, &m.BusinessType, ®Num, - &m.BusinessAddress, &m.OwnerName, &m.BusinessEmail, &m.BusinessPhone, &m.Status, + &m.BusinessAddress, &city, &postal, &m.OwnerName, &m.BusinessEmail, &m.BusinessPhone, &m.Status, &commRate, &setBank, &setName, &setAcct, &m.CreatedAt, - &dtiDoc, &birDoc, &otherDoc, + &dtiDoc, &birDoc, &otherDoc, &docStatus, ) if err != nil { @@ -604,6 +656,12 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request if regNum.Valid { m.RegistrationNum = regNum.String } + if city.Valid { + m.City = city.String + } + if postal.Valid { + m.PostalCode = postal.String + } if commRate.Valid { m.CommissionRate = commRate.Float64 @@ -618,7 +676,7 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request m.SettlementAcct = setAcct.String } if dtiDoc.Valid { - m.DtiDocument = dtiDoc.String + m.BusinessDocument = dtiDoc.String } if birDoc.Valid { m.BirDocument = birDoc.String @@ -626,6 +684,9 @@ func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request if otherDoc.Valid { m.OtherDocument = otherDoc.String } + if docStatus.Valid { + m.DocumentStatus = docStatus.String + } jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..6256a61 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,88 @@ +package authentication + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret = []byte(getEnv("JWT_SECRET", "super-secret-key")) // fallback for dev + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +type JWTClaims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// GenerateTokens creates a short-lived access token and a long-lived refresh token +func GenerateTokens(userID, role string) (string, string, error) { + // Access Token (15 minutes) + accessExpiration := time.Now().Add(15 * time.Minute) + accessClaims := &JWTClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessExpiration), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "unicard-auth", + Subject: "access", + }, + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(jwtSecret) + if err != nil { + return "", "", err + } + + // Refresh Token (7 days) + refreshExpiration := time.Now().Add(7 * 24 * time.Hour) + refreshClaims := &JWTClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExpiration), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "unicard-auth", + Subject: "refresh", + }, + } + refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtSecret) + if err != nil { + return "", "", err + } + + return accessToken, refreshToken, nil +} + +// ValidateJWT parses and validates the JWT, returning the claims if valid. +func ValidateJWT(tokenString string) (*JWTClaims, error) { + claims := &JWTClaims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Ensure the signing method is what we expect + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} diff --git a/backend/internal/auth/login.go b/backend/internal/auth/login.go index ed96570..1ed277d 100644 --- a/backend/internal/auth/login.go +++ b/backend/internal/auth/login.go @@ -5,6 +5,7 @@ import ( "errors" "log" "net/http" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" "github.com/go-playground/validator/v10" // For struct validation @@ -114,6 +115,39 @@ func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) { redirectURL = "/merchant/" + userName + "/dashboard" // Merchant dashboard } + // Generate JWT Tokens (Access & Refresh) + accessToken, refreshToken, err := GenerateTokens(ID, role) + if err != nil { + log.Printf("Error generating tokens: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Internal server error during login", + }) + return + } + + // Set Access Token as HttpOnly cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: accessToken, + Expires: time.Now().Add(1 * time.Minute), // 15 minutes expiration + HttpOnly: true, + Secure: true, // Important for SameSite=StrictMode + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Set Refresh Token as HttpOnly cookie + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: refreshToken, + Expires: time.Now().Add(7 * 24 * time.Hour), // 7 days expiration + HttpOnly: true, + Secure: true, // Important for SameSite=StrictMode + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + // SUCCESS User Login log.Printf("Login success for user: %s", loginReq.Identifier) jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.LoginResponse{ diff --git a/backend/internal/auth/logout.go b/backend/internal/auth/logout.go new file mode 100644 index 0000000..94fcab4 --- /dev/null +++ b/backend/internal/auth/logout.go @@ -0,0 +1,34 @@ +package authentication + +import ( + "net/http" + "time" +) + +// LogoutHandler clears the authentication cookies and redirects the user to the login page. +func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + // Clear the access token cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Clear the refresh token cookie + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Redirect to login page + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/backend/internal/auth/merchant_signup.go b/backend/internal/auth/merchant_signup.go index 896806f..c4d5d8b 100644 --- a/backend/internal/auth/merchant_signup.go +++ b/backend/internal/auth/merchant_signup.go @@ -20,20 +20,20 @@ import ( ) type MerchantSignupRequest struct { - BusinessName string `json:"businessName" validate:"required"` - BusinessType string `json:"businessType" validate:"required"` - BusinessAddress string `json:"businessAddress" validate:"required"` - OwnerName string `json:"ownerName" validate:"required"` - BusinessPhone string `json:"businessPhone" validate:"required"` - BusinessEmail string `json:"businessEmail" validate:"required,email"` - Password string `json:"password" validate:"required,min=6"` - DtiDocument string `json:"dtiDocument"` - BirDocument string `json:"birDocument"` - OtherDocument string `json:"otherDocument"` + BusinessName string `json:"businessName" validate:"required"` + BusinessType string `json:"businessType" validate:"required"` + BusinessAddress string `json:"businessAddress" validate:"required"` + OwnerName string `json:"ownerName" validate:"required"` + BusinessPhone string `json:"businessPhone" validate:"required"` + BusinessEmail string `json:"businessEmail" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` + BusinessDocument string `json:"businessDocument"` + BirDocument string `json:"birDocument"` + OtherDocument string `json:"otherDocument"` } // Helper to save base64 to file -func saveBase64ToFile(b64data, merchantID, docType string) string { +func saveBase64ToFile(b64data string) string { if b64data == "" { return "" } @@ -53,9 +53,9 @@ func saveBase64ToFile(b64data, merchantID, docType string) string { } // Create directory if not exists os.MkdirAll("./storage/documents", os.ModePerm) - fileName := fmt.Sprintf("%s_%s_%d%s", merchantID, docType, time.Now().Unix(), ext) + fileName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) filePath := filepath.Join("./storage/documents", fileName) - + err = os.WriteFile(filePath, data, 0644) if err != nil { return "" @@ -184,23 +184,23 @@ func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) regNum := fmt.Sprintf("UCBZ-%s-%010d", time.Now().Format("010205"), nReg.Int64()) // Save Documents - dtiPath := saveBase64ToFile(req.DtiDocument, merchantID, "DTI") - birPath := saveBase64ToFile(req.BirDocument, merchantID, "BIR") - otherPath := saveBase64ToFile(req.OtherDocument, merchantID, "OTHER") + bizDocPath := saveBase64ToFile(req.BusinessDocument) + birPath := saveBase64ToFile(req.BirDocument) + otherPath := saveBase64ToFile(req.OtherDocument) // Insert Merchant with placeholder 'PENDING' for settlement fields fixedCommissionRate := 2.00 merchStmt := `INSERT INTO merchants ( merchant_id, business_name, business_type, business_registration_number, business_address, user_id, owner_name, business_email, business_phone, commission_rate, - status, dti_document, bir_document, other_document + status, business_document, bir_document, other_document ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - + _, err = tx.ExecContext(ctx, merchStmt, merchantID, req.BusinessName, req.BusinessType, regNum, req.BusinessAddress, userID, req.OwnerName, req.BusinessEmail, req.BusinessPhone, fixedCommissionRate, "pending approval", - dtiPath, birPath, otherPath, + bizDocPath, birPath, otherPath, ) if err != nil { diff --git a/backend/internal/auth/refresh.go b/backend/internal/auth/refresh.go new file mode 100644 index 0000000..fb6f764 --- /dev/null +++ b/backend/internal/auth/refresh.go @@ -0,0 +1,71 @@ +package authentication + +import ( + "log" + "net/http" + "time" + jsonwrite "unicard-go/backend/internal/pkg/handler" +) + +// RefreshTokenHandler handles the generation of new access tokens using a valid refresh token. +func (h *Handler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { + // 1. Get the refresh token from the cookie + cookie, err := r.Cookie("refresh_token") + if err != nil { + jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ + Success: false, + Message: "Unauthorized: Missing refresh token", + }) + return + } + + // 2. Validate the refresh token + claims, err := ValidateJWT(cookie.Value) + if err != nil || claims.Subject != "refresh" { + log.Printf("Invalid refresh token attempt: %v", err) + jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ + Success: false, + Message: "Unauthorized: Invalid or expired refresh token", + }) + return + } + + // 3. Generate new tokens + accessToken, newRefreshToken, err := GenerateTokens(claims.UserID, claims.Role) + if err != nil { + log.Printf("Error generating tokens during refresh: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Internal server error", + }) + return + } + + // 4. Set the new Access Token cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: accessToken, + Expires: time.Now().Add(15 * time.Minute), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // 5. Set the new Refresh Token cookie (sliding expiration) + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: newRefreshToken, + Expires: time.Now().Add(7 * 24 * time.Hour), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // 6. Return success response + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "Token refreshed successfully", + }) +} diff --git a/backend/internal/merchant/account.go b/backend/internal/merchant/account.go index 7e47b67..2a9ce09 100644 --- a/backend/internal/merchant/account.go +++ b/backend/internal/merchant/account.go @@ -1,8 +1,15 @@ package merchant import ( + "encoding/json" + "fmt" + "io" "log" "net/http" + "os" + "path/filepath" + "strings" + "time" jsonwrite "unicard-go/backend/internal/pkg/handler" ) @@ -19,8 +26,8 @@ type BusinessDetails struct { type BusinessDocument struct { DocumentType string `json:"document_type"` - Status string `json:"status"` - Message string `json:"message,omitempty"` + Status string `json:"document_status"` + Message string `json:"message"` BusinessStructure string `json:"business_structure"` DocumentURL string `json:"document_url"` } @@ -34,12 +41,36 @@ type BusinessBankDetails struct { type AccountSummary struct { MerchantID string `json:"merchant_id"` AccountStatus string `json:"account_status"` + DocumentStatus string `json:"document_status"` + AccountMessage string `json:"account_message"` MemberSince string `json:"member_since"` BusinessDetails BusinessDetails `json:"business_details"` BusinessBankDetails BusinessBankDetails `json:"business_bank_details"` BusinessDocuments []BusinessDocument `json:"business_document"` } +type MerchantDetails struct { + merchantID string `db:"merchant_id"` + accountStatus string `db:"status"` + businessName string `db:"business_name"` + businessType string `db:"business_type"` + businessStructure string `db:"business_structure"` + businessEmail string `db:"business_email"` + businessPhone string `db:"business_phone"` + businessAddress string `db:"business_address"` + city string `db:"city"` + postalCode string `db:"postal_code"` + accName string `db:"settlement_account_name"` + bankName string `db:"settlement_bank_name"` + accNumber string `db:"settlement_account_number"` + businessDoc string `db:"business_document,bir_document"` + birDoc string `db:"bir_document"` + otherDoc string `db:"other_document"` + docStatus string `db:"document_status"` + docMessage string `db:"message"` + createdAtStr string `db:"created_at"` +} + // MerchantAccountView renders the merchant_account.html template func (h *Handler) MerchantAccountView(w http.ResponseWriter, r *http.Request) { log.Println("MerchantAccountView running...") @@ -67,68 +98,43 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ return } - // 1. Holds the data fetched from the database - var ( - merchantID, accountStatus, businessName, businessType, businessStructure, - businessEmail, businessPhone, businessAddress, city, - postalCode, accName, bankName, accNumber, businessDoc, birDoc, - docStatus, docMessage, createdAtStr string - ) + // Holds the data fetched from the database + var merchant MerchantDetails - // 2. Execute the full JOIN query + // Execute the full JOIN query err := h.DB.QueryRowContext(ctx, ` SELECT m.merchant_id, COALESCE(m.status, ''), COALESCE(DATE_FORMAT(m.created_at, '%M %d, %Y'), '') as created_at, - -- Business Info COALESCE(m.business_name, ''), COALESCE(m.business_type, ''), - COALESCE(m.business_structure, ''), COALESCE(m.business_email, ''), COALESCE(m.business_phone, ''), COALESCE(m.business_address, ''), - -- Location Info COALESCE(m.city, ''), - COALESCE(m.postal_code, ''), - + COALESCE(m.postal_code, ''), -- Bank Info COALESCE(m.settlement_account_name, ''), COALESCE(m.settlement_bank_name, ''), COALESCE(m.settlement_account_number, ''), - -- Document Info - COALESCE(m.business_structure, ''), COALESCE(m.business_document, ''), COALESCE(m.bir_document, ''), + COALESCE(m.other_document, ''), COALESCE(m.document_status, ''), COALESCE(m.message, '') - FROM merchants m JOIN users u ON m.user_id = u.user_id WHERE u.username = ? `, username).Scan( - &merchantID, - &accountStatus, - &createdAtStr, - &businessName, - &businessType, - &businessStructure, - &businessEmail, - &businessPhone, - &businessAddress, - &city, - &postalCode, - &accName, - &bankName, - &accNumber, - &businessStructure, - &businessDoc, - &birDoc, - &docStatus, - &docMessage, + &merchant.merchantID, &merchant.accountStatus, &merchant.createdAtStr, &merchant.businessName, + &merchant.businessType, &merchant.businessEmail, &merchant.businessPhone, + &merchant.businessAddress, &merchant.city, &merchant.postalCode, &merchant.accName, + &merchant.bankName, &merchant.accNumber, &merchant.businessDoc, + &merchant.birDoc, &merchant.otherDoc, &merchant.docStatus, &merchant.docMessage, ) if err != nil { @@ -140,76 +146,217 @@ func (h *Handler) MerchantAccountDataHandler(w http.ResponseWriter, r *http.Requ return } - // 3. Format UI Logic - memberSince := createdAtStr + // Format UI Logic + memberSince := merchant.createdAtStr var maskedAccount string - if len(accNumber) > 4 { - maskedAccount = "**** **** **** " + accNumber[len(accNumber)-4:] - } else if accNumber != "" { - maskedAccount = accNumber + if len(merchant.accNumber) > 4 { + maskedAccount = "**** **** **** " + merchant.accNumber[len(merchant.accNumber)-4:] + } else if merchant.accNumber != "" { + maskedAccount = merchant.accNumber } - // 4. Update the dynamic Document Array logic + // Update the dynamic Document Array logic documents := []BusinessDocument{} // Business Registration (DTI or SEC) - if businessDoc != "" { - registrationLabel := "DTI Registration" - - // NOW checking the correct column! - if businessStructure == "corporation" || businessStructure == "partnership" { - registrationLabel = "SEC Registration" - } + if merchant.businessDoc != "" { + registrationLabel := "DTI/SEC Registration" documents = append(documents, BusinessDocument{ DocumentType: registrationLabel, - Status: docStatus, // Quotes removed! - Message: docMessage, // Quotes removed! - BusinessStructure: businessStructure, - DocumentURL: businessDoc, + Status: merchant.docStatus, // Quotes removed! + Message: merchant.docMessage, // Quotes removed! + BusinessStructure: merchant.businessStructure, + DocumentURL: merchant.businessDoc, }) } // Tax Registration (BIR) - if birDoc != "" { + if merchant.birDoc != "" { documents = append(documents, BusinessDocument{ DocumentType: "BIR Certificate", - Status: docStatus, // Quotes removed! - Message: docMessage, // Quotes removed! - DocumentURL: birDoc, + Status: merchant.docStatus, + Message: merchant.docMessage, + DocumentURL: merchant.birDoc, }) } - // 5. Construct the final struct - responseData := AccountSummary{ - MerchantID: merchantID, - AccountStatus: accountStatus, - MemberSince: memberSince, + // Other Document + if merchant.otherDoc != "" { + documents = append(documents, BusinessDocument{ + DocumentType: "Other Document", + Status: merchant.docStatus, + Message: merchant.docMessage, + DocumentURL: merchant.otherDoc, + }) + } + // Construct the final struct + responseData := AccountSummary{ + MerchantID: merchant.merchantID, + AccountStatus: merchant.accountStatus, + DocumentStatus: merchant.docStatus, + AccountMessage: merchant.docMessage, + MemberSince: memberSince, BusinessDetails: BusinessDetails{ - BusinessName: businessName, - BusinessType: businessType, - BusinessEmail: businessEmail, - BusinessPhone: businessPhone, - BusinessAddress: businessAddress, - City: city, - PostalCode: postalCode, + BusinessName: merchant.businessName, + BusinessType: merchant.businessType, + BusinessEmail: merchant.businessEmail, + BusinessPhone: merchant.businessPhone, + BusinessAddress: merchant.businessAddress, + City: merchant.city, + PostalCode: merchant.postalCode, }, - BusinessBankDetails: BusinessBankDetails{ - AccountHolderName: accName, - BankName: bankName, + AccountHolderName: merchant.accName, + BankName: merchant.bankName, AccountNumber: maskedAccount, }, - BusinessDocuments: documents, } - // 6. Send response + // Send response jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, Message: "Account profile retrieved successfully", Data: responseData, }) } + +func (h *Handler) UpdateBankDetails(w http.ResponseWriter, r *http.Request) { + log.Println("UpdateBankDetails running...") + username := r.PathValue("username") + if username == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Username required"}) + return + } + + var req BusinessBankDetails + 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 strings.TrimSpace(req.BankName) == "" || strings.TrimSpace(req.AccountHolderName) == "" || strings.TrimSpace(req.AccountNumber) == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "All bank details fields are required"}) + return + } + + var merchantID string + err := h.DB.QueryRow("SELECT merchant_id FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username=?)", username).Scan(&merchantID) + if err != nil { + log.Println("Error finding merchant for update:", err) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) + return + } + + _, err = h.DB.Exec("UPDATE merchants SET settlement_bank_name=?, settlement_account_name=?, settlement_account_number=? WHERE merchant_id = ?", req.BankName, req.AccountHolderName, req.AccountNumber, merchantID) + + if err != nil { + log.Println("Update error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update bank details"}) + return + } + + // Insert a system transaction to log the update + sysTxnID := fmt.Sprintf("SYS-SETTLE-%d", time.Now().UnixMilli()) + _, _ = h.DB.Exec(` + INSERT INTO transactions + (transaction_id, merchant_id, transaction_type, amount, points_earned, service_fee, status, description) + VALUES (?, ?, 'payment', NULL, NULL, NULL, 'completed', 'Settlement bank details were updated by the merchant.')`, + sysTxnID, merchantID) + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Bank details updated"}) +} + +func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) { + log.Println("UploadDocument running...") + username := r.PathValue("username") + if username == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Username required"}) + return + } + + err := r.ParseMultipartForm(4 << 20) // Limit memory to 4MB + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large"}) + return + } + + docType := r.FormValue("document_type") + file, handler, err := r.FormFile("document") + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Failed to read file"}) + return + } + defer file.Close() + + if handler.Size > 4*1024*1024 { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "File too large. Maximum size is 4MB."}) + return + } + + ext := strings.ToLower(filepath.Ext(handler.Filename)) + validExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".pdf": true, + } + if !validExts[ext] { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid file format. Only pictures, PDF, and Word docs are allowed."}) + return + } + + // Ensure uploads directory exists + uploadDir := "storage/documents" + os.MkdirAll(uploadDir, os.ModePerm) + + // Save file with auto-generated filename to eliminate raw filename + filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + filePath := filepath.Join(uploadDir, filename) + dst, err := os.Create(filePath) + if err != nil { + log.Println("File create error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Internal server error"}) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, file); err != nil { + log.Println("File copy error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Internal server error"}) + return + } + + dbPath := "/" + strings.ReplaceAll(filePath, "\\", "/") + + col := "business_document" + switch docType { + case "BIR Certificate": + col = "bir_document" + case "Other Document": + col = "other_document" + } + + // Remove old file if it exists + var oldDbPath *string + qOld := fmt.Sprintf("SELECT %s FROM merchants WHERE user_id = (SELECT user_id FROM users WHERE username=?)", col) + if err := h.DB.QueryRow(qOld, username).Scan(&oldDbPath); err == nil && oldDbPath != nil { + oldFile := strings.TrimPrefix(*oldDbPath, "/") + if oldFile != "" { + os.Remove(oldFile) // Best effort delete + } + } + + query := fmt.Sprintf("UPDATE merchants SET %s=?, document_status='Pending' WHERE user_id = (SELECT user_id FROM users WHERE username=?)", col) + _, err = h.DB.Exec(query, dbPath, username) + if err != nil { + log.Println("DB update error:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update DB"}) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "File uploaded successfully"}) +} diff --git a/backend/internal/merchant/dashboard.go b/backend/internal/merchant/dashboard.go index a54a4c3..961cb7c 100644 --- a/backend/internal/merchant/dashboard.go +++ b/backend/internal/merchant/dashboard.go @@ -43,7 +43,8 @@ type MerchantSummary struct { AvailableBalance decimal.Decimal `json:"available_balance"` MonthlyNetIncome decimal.Decimal `json:"monthly_net_income"` SettlementBank *string `json:"settlement_bank"` - SettlementAccount *string `json:"settlement_account"` + SettlementAccount *string `json:"settlement_account_number"` + SettlementName *string `json:"settlement_account_name"` RecentTransactions []MerchantTransaction `json:"recent_transactions"` } @@ -83,16 +84,16 @@ func (h *Handler) GetMerchantRecentTransactions(ctx context.Context, merchantID log.Println("GetMerchantRecentTransactions running...") rows, err := h.DB.QueryContext(ctx, ` - SELECT + SELECT transaction_id, COALESCE(card_number, ''), merchant_id, terminal_id, - COALESCE(transaction_type, ''), amount, + COALESCE(transaction_type, ''), COALESCE(amount, 0), COALESCE(points_earned, 0), COALESCE(service_fee, 0), COALESCE(net_merchant_payout, 0), processed_by, COALESCE(status, ''), description, COALESCE(created_at, '') - FROM transactions - WHERE merchant_id = ? - ORDER BY created_at DESC + FROM transactions + WHERE merchant_id = ? + ORDER BY created_at DESC LIMIT 10`, merchantID) if err != nil { diff --git a/backend/internal/merchant/incomes.go b/backend/internal/merchant/incomes.go index 85f1dab..280fbc2 100644 --- a/backend/internal/merchant/incomes.go +++ b/backend/internal/merchant/incomes.go @@ -22,12 +22,6 @@ type IncomeHistory struct { TerminalID *string `json:"terminal_id" db:"terminal_id"` } -// IncomeStat represents the merchant's income statistics -// TotalCollected → Total money collected from customers (gross, before Unicard fee) -// UniCardFee → Unicard's platform cut deducted per transaction -// TotalEarned → What the merchant receives after Unicard's fee -// TotalRefunded → Money returned back to customers -// ActualIncome → What the merchant actually keeps after refunds (real bottom line) // IncomeStat represents the merchant's income statistics type IncomeStat struct { NetRevenue decimal.Decimal `json:"net_revenue"` // What the merchant gets after platform fee @@ -118,7 +112,7 @@ func (h *Handler) GetMerchantIncomeHistory(ctx context.Context, merchantID strin SELECT COALESCE(created_at, ''), description, transaction_id, COALESCE(card_number, ''), - COALESCE(transaction_type, ''), amount, + COALESCE(transaction_type, ''), COALESCE(amount, 0), COALESCE(net_merchant_payout, 0), COALESCE(service_fee, 0), processed_by, terminal_id FROM transactions diff --git a/backend/internal/merchant/transactions.go b/backend/internal/merchant/transactions.go index de47b46..7f7958c 100644 --- a/backend/internal/merchant/transactions.go +++ b/backend/internal/merchant/transactions.go @@ -61,7 +61,7 @@ func (h *Handler) TransactionHandler(w http.ResponseWriter, r *http.Request) { query := `SELECT transaction_id, COALESCE(card_number, ''), merchant_id, terminal_id, - COALESCE(transaction_type, ''), amount, + COALESCE(transaction_type, ''), COALESCE(amount, 0), COALESCE(points_earned, 0), COALESCE(service_fee, 0), COALESCE(net_merchant_payout, 0), processed_by, COALESCE(status, ''), description, COALESCE(created_at, '') diff --git a/backend/internal/merchant/withdraw.go b/backend/internal/merchant/withdraw.go index 9269a30..579c19e 100644 --- a/backend/internal/merchant/withdraw.go +++ b/backend/internal/merchant/withdraw.go @@ -1,18 +1,30 @@ package merchant import ( + "context" "database/sql" "encoding/json" "fmt" "log" "net/http" + "os" + "strings" "time" jsonwrite "unicard-go/backend/internal/pkg/handler" "github.com/shopspring/decimal" + xendit "github.com/xendit/xendit-go/v7" + "github.com/xendit/xendit-go/v7/payout" ) +type BankDetails struct { + merchantID string + settlementBank *string + settlementAccountName *string + settlementAccount *string +} + type WithdrawRequest struct { Amount float64 `json:"amount"` } @@ -21,7 +33,7 @@ type WithdrawRequest struct { func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { log.Println("WithdrawHandler running...") - // 1. Get username from URL + // Get username from URL username := r.PathValue("username") if username == "" { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ @@ -31,7 +43,7 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 2. Parse request payload + // Parse request payload var req WithdrawRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ @@ -49,19 +61,14 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 3. Fetch Merchant Info (ID, Settlement Details) - var ( - merchantID string - settlementBank *string - settlementAccount *string - ) - + // Fetch Merchant Info (ID, Settlement Details) + var bank BankDetails err := h.DB.QueryRow(` - SELECT m.merchant_id, m.settlement_bank_name, m.settlement_account_number + SELECT m.merchant_id, m.settlement_bank_name, m.settlement_account_name, m.settlement_account_number FROM merchants m JOIN users u ON m.user_id = u.user_id WHERE u.username = ? LIMIT 1 - `, username).Scan(&merchantID, &settlementBank, &settlementAccount) + `, username).Scan(&bank.merchantID, &bank.settlementBank, &bank.settlementAccountName, &bank.settlementAccount) if err != nil { if err == sql.ErrNoRows { @@ -80,7 +87,7 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { } // Validate settlement details - if settlementBank == nil || settlementAccount == nil { + if bank.settlementBank == nil || bank.settlementAccount == nil || bank.settlementAccountName == nil { jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ Success: false, Message: "Please set up your settlement bank account details in your profile before withdrawing.", @@ -88,8 +95,8 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 4. Calculate Available Balance - stats, err := h.GetMerchantIncomeStats(r.Context(), merchantID) + // Calculate Available Balance + stats, err := h.GetMerchantIncomeStats(r.Context(), bank.merchantID) if err != nil { log.Println("Error fetching income stats:", err) jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ @@ -108,38 +115,75 @@ func (h *Handler) WithdrawHandler(w http.ResponseWriter, r *http.Request) { return } - // 5. Generate Transaction ID + // Generate Transaction ID // Format: TXN-WD-timestamp txnID := fmt.Sprintf("TXN-WD-%d", time.Now().UnixNano()) - accountNum := *settlementAccount + // getting the last 4 digit of bank account number + accountNum := *bank.settlementAccount last4 := accountNum if len(accountNum) > 4 { last4 = accountNum[len(accountNum)-4:] } - description := fmt.Sprintf("Withdrawal to %s ending in %s", *settlementBank, last4) - + description := fmt.Sprintf("Withdrawal to %s ending in %s", *bank.settlementBank, last4) + + // Create Xendit Payout (v7) + xenditClient := xendit.NewClient(os.Getenv("XENDIT_SECRET_KEY")) + payoutClient := payout.NewPayoutApi(xenditClient) + + channelProps := payout.NewDigitalPayoutChannelProperties(*bank.settlementAccount) + channelProps.SetAccountHolderName(*bank.settlementAccountName) + + createPayoutReq := payout.NewCreatePayoutRequest( + txnID, + "PH_"+strings.ToUpper(*bank.settlementBank), // Channel code e.g. "PH_MAYA" + *channelProps, + float32(req.Amount), + "PHP", + ) + createPayoutReq.SetDescription(description) + + _, _, payoutErr := payoutClient.CreatePayout(context.Background()). + IdempotencyKey(txnID). + CreatePayoutRequest(*createPayoutReq). + Execute() + + if payoutErr != nil { + log.Printf("Failed to create Xendit payout: %v", payoutErr.Error()) + log.Printf("Data sent to Xendit: %+v", createPayoutReq) + log.Printf("Xendit Detailed Error: %v", payoutErr.Error()) + + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to process withdrawal via payment gateway", + }) + return + } + insertTxnQuery := ` INSERT INTO transactions ( transaction_id, merchant_id, transaction_type, amount, status, description, card_number - ) VALUES (?, ?, 'withdrawal', ?, 'completed', ?, NULL) + ) VALUES (?, ?, 'withdrawal', ?, 'pending', ?, NULL) ` - _, err = h.DB.Exec(insertTxnQuery, txnID, merchantID, req.Amount, description) + _, err = h.DB.Exec(insertTxnQuery, txnID, bank.merchantID, req.Amount, description) if err != nil { log.Println("Error inserting withdrawal transaction:", err) + // We could potentially try to cancel the disbursement here, or have a manual reconciliation process. + // For now, we return an error. jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ Success: false, - Message: "Failed to process withdrawal", + Message: "Withdrawal initiated, but failed to record in database. Please contact support.", }) return } jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ Success: true, - Message: "Withdrawal processed successfully", + Message: "Withdrawal is being processed", Data: map[string]interface{}{ "transaction_id": txnID, "amount": req.Amount, + "status": "pending", }, }) } diff --git a/backend/internal/merchant/xendit_disbursement_webhook.go b/backend/internal/merchant/xendit_disbursement_webhook.go new file mode 100644 index 0000000..571baca --- /dev/null +++ b/backend/internal/merchant/xendit_disbursement_webhook.go @@ -0,0 +1,74 @@ +package merchant + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" +) + +// XenditPayoutWebhookPayload represents the expected payload from Xendit Payout webhook +type XenditPayoutWebhookPayload struct { + Event string `json:"event"` + Data struct { + ReferenceID string `json:"reference_id"` + Status string `json:"status"` // SUCCEEDED, FAILED + ChannelCode string `json:"channel_code"` + Amount float64 `json:"amount"` + FailureCode string `json:"failure_code,omitempty"` + } `json:"data"` +} + +// XenditDisbursementWebhook handles incoming webhook notifications from Xendit for disbursements. +func (h *Handler) XenditDisbursementWebhook(w http.ResponseWriter, r *http.Request) { + // Verify Xendit Callback Token + xenditToken := os.Getenv("XENDIT_WEBHOOK_KEY") + callbackToken := r.Header.Get("x-callback-token") + + if xenditToken != "" && callbackToken != xenditToken { + log.Println("Invalid x-callback-token for disbursement") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Println("Failed to read body") + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + var payload XenditPayoutWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + log.Println("Failed to parse webhook JSON:", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + externalID := payload.Data.ReferenceID + + // Payout events: payout.succeeded, payout.failed + switch payload.Event { + case "payout.succeeded": + _, err := h.DB.Exec(`UPDATE transactions SET status = 'completed' WHERE transaction_id = ? AND transaction_type = 'withdrawal' AND status = 'pending'`, externalID) + if err != nil { + log.Println("Failed to update withdrawal transaction to completed:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + log.Printf("Successfully disbursed ₱%.2f for transaction %s", payload.Data.Amount, externalID) + + case "payout.failed": + _, err := h.DB.Exec(`UPDATE transactions SET status = 'failed' WHERE transaction_id = ? AND transaction_type = 'withdrawal' AND status = 'pending'`, externalID) + if err != nil { + log.Println("Failed to update withdrawal transaction to failed:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + log.Printf("Disbursement failed for transaction %s. Reason: %s", externalID, payload.Data.FailureCode) + } + + // Always 200 OK to Xendit + w.WriteHeader(http.StatusOK) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..353365e --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "context" + "log" + "net/http" + "strings" + "time" + authentication "unicard-go/backend/internal/auth" + jsonwrite "unicard-go/backend/internal/pkg/handler" +) + +type contextKey string + +const UserClaimsKey contextKey = "user_claims" + +// RequireAuth is a middleware that checks for a valid JWT in the Authorization header +// or HttpOnly cookie and ensures the user has one of the allowed roles. +func RequireAuth(allowedRoles ...string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // Helper to handle unauthorized/forbidden gracefully depending on endpoint type + handleAuthError := func(w http.ResponseWriter, r *http.Request, statusCode int, msg string) { + // Check if it's an API request (starts with /v1/ or /api/) + if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/api/") { + jsonwrite.WriteJSON(w, statusCode, jsonwrite.APIResponse{ + Success: false, + Message: msg, + }) + } else { + // It's a view, redirect to login + http.Redirect(w, r, "/login", http.StatusSeeOther) + } + } + + var claims *authentication.JWTClaims + var tokenValid bool + var tokenString string + + // Extract Access Token from Header OR Cookie + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + // Extract from Authorization header (API / Thunder Client / cURL) + tokenString = strings.TrimPrefix(authHeader, "Bearer ") + } else { + // Fallback: Check if token is in the Cookie (Web Browsers) + cookie, err := r.Cookie("jwt") + if err == nil { + tokenString = cookie.Value + } + } + + // If we found a token in either place, validate it + if tokenString != "" { + parsedClaims, err := authentication.ValidateJWT(tokenString) + if err == nil && parsedClaims.Subject == "access" { + claims = parsedClaims + tokenValid = true + } else if err != nil { + log.Printf("DEBUG: JWT Validation failed: %v", err) + } + } + + // 2. If Access Token is missing or invalid, try Silent Refresh + if !tokenValid { + refreshCookie, err := r.Cookie("refresh_token") + if err == nil { + refreshClaims, err := authentication.ValidateJWT(refreshCookie.Value) + if err == nil && refreshClaims.Subject == "refresh" { + // Valid refresh token! Generate new tokens. + newAccess, newRefresh, err := authentication.GenerateTokens(refreshClaims.UserID, refreshClaims.Role) + if err == nil { + // Set new cookies + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: newAccess, + Expires: time.Now().Add(15 * time.Minute), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: newRefresh, + Expires: time.Now().Add(7 * 24 * time.Hour), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + + // Treat request as valid using the refresh claims + claims = refreshClaims + tokenValid = true + } + } + } + } + + if !tokenValid { + handleAuthError(w, r, http.StatusUnauthorized, "Unauthorized: Missing or invalid authentication token") + return + } + + // 3. Check role-based access control (RBAC) + roleAllowed := false + if len(allowedRoles) == 0 { + // If no specific roles are required, any valid token is sufficient + roleAllowed = true + } else { + for _, role := range allowedRoles { + if claims.Role == role { + roleAllowed = true + break + } + } + } + + if !roleAllowed { + handleAuthError(w, r, http.StatusForbidden, "Forbidden: Insufficient privileges") + return + } + + // Pass the claims to the next handler via request context + ctx := context.WithValue(r.Context(), UserClaimsKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} \ No newline at end of file diff --git a/backend/internal/middleware/rateLimit.go b/backend/internal/middleware/rateLimit.go index 15f993f..54f1dce 100644 --- a/backend/internal/middleware/rateLimit.go +++ b/backend/internal/middleware/rateLimit.go @@ -1,4 +1,4 @@ -package authentication +package middleware import ( "encoding/json" diff --git a/backend/internal/pkg/smtpbody/smtp.go b/backend/internal/pkg/smtpbody/smtp.go index caec117..668f5d1 100644 --- a/backend/internal/pkg/smtpbody/smtp.go +++ b/backend/internal/pkg/smtpbody/smtp.go @@ -285,6 +285,45 @@ func EmailVerificationBody() string { + +` +} + +func MerchantDeletedEmail() string { + return ` + + + + Unicard Account Deleted + + + +
+
+

Unicard

+
+
+

Hello %s,

+

This is a formal notice that your Unicard merchant account has been permanently deleted by an administrator.

+
+ Note:
+ All terminals associated with your account have been deactivated and unregistered. Your account access has been revoked. +
+

If you have any pending settlements or questions regarding this action, please contact our support team immediately.

+

Thank you,
The Unicard Team

+
+
diff --git a/backend/internal/user/customer_topup.go b/backend/internal/user/customer_topup.go index ee1f941..ffea206 100644 --- a/backend/internal/user/customer_topup.go +++ b/backend/internal/user/customer_topup.go @@ -107,9 +107,57 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { // set xendit secret key xendit.Opt.SecretKey = os.Getenv("XENDIT_SECRET_KEY") - // Encode metadata into ExternalID since Xendit v1 doesn't have metadata field on CreateParams - // Format: TOPUP{CardNumber}{TopupAmount}{FeeAmount}{Timestamp} - externalID := fmt.Sprintf("TOPUP%s%.2f%.2f%d", cardNumber, topupAmount, feeAmount, time.Now().UnixNano()) + // Generate Unique IDs + topupID := fmt.Sprintf("TOPUP-%d", time.Now().UnixNano()) + transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) + + // Start Database Transaction to insert PENDING records + tx, dbErr := h.DB.Begin() + if dbErr != nil { + log.Println("Failed to start transaction:", dbErr) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Internal Server Error", + }) + return + } + + // Insert into Loading Ledger (top_ups table) with status = 'pending' + queryTopUp := `INSERT INTO top_ups (topup_id, card_number, amount, convenience_fee, gateway_cost, payment_method, handled_by, external_id, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + if _, err := tx.Exec(queryTopUp, topupID, cardNumber, topupAmount, feeAmount, 0.0, "xendit", "payment gateway", topupID, "pending"); err != nil { + tx.Rollback() + log.Println("Failed to record pending top-up:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to record pending top-up", + }) + return + } + + // Insert into Spending Ledger (transactions table) with status = 'pending' + queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by, description, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + if _, err := tx.Exec(queryTx, transactionID, cardNumber, "xendit", "xendit", "topup", topupAmount, feeAmount, "xendit", "Pending topup via Xendit", "pending"); err != nil { + tx.Rollback() + log.Println("Failed to record pending transaction:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to record pending transaction", + }) + return + } + + // Commit the pending records + if err := tx.Commit(); err != nil { + log.Println("Failed to finalize pending records:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to finalize database changes", + }) + return + } + + // The Xendit ExternalID will cleanly map to our topup_id + externalID := topupID // create xendit invoice struct with parameters data := invoice.CreateParams{ @@ -119,8 +167,10 @@ func (h *Handler) CreateXenditInvoice(w http.ResponseWriter, r *http.Request) { Description: fmt.Sprintf("Unicard Top-Up (Card: %s)", cardNumber), SuccessRedirectURL: domain + "/u/" + username + "/dashboard", FailureRedirectURL: domain + "/u/" + username + "/topup", - PaymentMethods: []string{"CREDIT_CARD", "EWALLET", "QR_CODE"}, - Currency: "PHP", + PaymentMethods: []string{"CREDIT_CARD", "GCASH", "PAYMAYA", "GRABPAY", + "SHOPEEPAY", "QRPH", "7ELEVEN", "CEBUANA", "ECPAY", + "BANK_TRANSFER"}, + Currency: "PHP", } // creating the checkout session diff --git a/backend/internal/user/xendit_webhook.go b/backend/internal/user/xendit_webhook.go index a4cc81a..7d0ffbc 100644 --- a/backend/internal/user/xendit_webhook.go +++ b/backend/internal/user/xendit_webhook.go @@ -2,14 +2,10 @@ package user import ( "encoding/json" - "fmt" "io" "log" "net/http" "os" - "strconv" - "strings" - "time" ) // XenditWebhookPayload represents the expected payload from Xendit Invoice webhook @@ -70,113 +66,81 @@ func (h *Handler) XenditWebhook(w http.ResponseWriter, r *http.Request) { // switch case for payment status switch payload.Status { case "PAID", "SETTLED": - externalID := payload.ExternalID - // check if external id is for top up - if strings.HasPrefix(externalID, "TOPUP") && len(externalID) > 21 { - // TOPUP is 5 chars, CardNumber is strictly 16 chars - // indexes 5 to 21 - cardNumber := externalID[5:21] - - remainder := externalID[21:] - // find first dot - firstDot := strings.Index(remainder, ".") - if firstDot == -1 || len(remainder) < firstDot+3 { - log.Println("Invalid external ID format: missing base amount") - http.Error(w, "Invalid external ID data", http.StatusBadRequest) - return - } - baseAmountStr := remainder[:firstDot+3] // e.g. 50.00 - - remainder2 := remainder[firstDot+3:] - // find second dot - secondDot := strings.Index(remainder2, ".") - if secondDot == -1 || len(remainder2) < secondDot+3 { - log.Println("Invalid external ID format: missing fee amount") - http.Error(w, "Invalid external ID data", http.StatusBadRequest) - return - } - convenienceFeeStr := remainder2[:secondDot+3] // e.g. 15.00 - - // convert string to float - baseAmount, err1 := strconv.ParseFloat(baseAmountStr, 64) - convenienceFee, err2 := strconv.ParseFloat(convenienceFeeStr, 64) - - if err1 != nil || err2 != nil { - log.Println("Failed to parse amounts from external ID") - http.Error(w, "Invalid external ID data", http.StatusBadRequest) - return - } - - // process successful top up - err := h.processSuccessfulTopUp(cardNumber, baseAmount, convenienceFee, 0.0, "xendit", payload.ExternalID) - if err != nil { - log.Println("Database transaction failed:", err) - w.WriteHeader(http.StatusOK) // don't return 500 to Xendit - return - } - - log.Printf("Successfully loaded ₱%.2f onto card %s via Xendit", baseAmount, cardNumber) - } else { - log.Println("Unrecognized External ID format:", payload.ExternalID) + externalID := payload.ExternalID // This maps to our topup_id + + // Start Database Transaction + tx, err := h.DB.Begin() + if err != nil { + log.Println("Failed to start transaction:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var cardNumber string + var amount float64 + var currentStatus string + + // Fetch the top-up record + err = tx.QueryRow(`SELECT card_number, amount, status FROM top_ups WHERE topup_id = ?`, externalID).Scan(&cardNumber, &amount, ¤tStatus) + if err != nil { + log.Println("Failed to find top-up record or invalid external_id:", err) + w.WriteHeader(http.StatusOK) // Ignore if not found + return } - // log if payment failed or expired - case "EXPIRED", "FAILED": - log.Printf("Payment failed for external ID: %s, status: %s", payload.ExternalID, payload.Status) - } - // Always 200 OK to Xendit - w.WriteHeader(http.StatusOK) -} + // Prevent double processing + if currentStatus == "completed" { + log.Println("Top-up already completed, skipping.") + w.WriteHeader(http.StatusOK) + return + } -// Helper function to handle the database transaction -func (h *Handler) processSuccessfulTopUp(cardNumber string, baseAmount float64, convenienceFee float64, paymentGatewayCost float64, paymentMethod string, externalID string) error { - topupID := fmt.Sprintf("TOPUP-%d", time.Now().UnixNano()) - transactionID := fmt.Sprintf("TX-%d", time.Now().UnixNano()) + // Update the User's Balance + if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, amount, cardNumber); err != nil { + log.Println("Failed to update card balance:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } - // begin transaction to make sure that all database operations are performed in a single unit of work - // if any operation fails, the entire transaction will be rolled back and the database will be left unchanged - tx, err := h.DB.Begin() - if err != nil { - return err - } - // if transaction fails, rollback not close the transaction - // Rollback() will do nothing if the transaction is already closed (committed or rolled back) - // so that's why we use defer tx.Rollback() - // This pattern ensures that the database connection is properly managed - // and that any errors during the transaction are handled gracefully. - defer tx.Rollback() - - // Check if already processed - // this prevents double top up if xendit sends multiple webhooks or network delay causes multiple requests - // using external_id as unique identifier for each top up - var exists bool - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM top_ups WHERE external_id = ?)`, externalID).Scan(&exists); err != nil { - return err - } - if exists { - return nil // already processed, skip silently - } + // Mark the top-up ledger as completed + if _, err := tx.Exec(`UPDATE top_ups SET status = 'completed' WHERE topup_id = ?`, externalID); err != nil { + log.Println("Failed to update top_ups status:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } - // Update User Balance - // Using atomic update to prevent race conditions - if _, err := tx.Exec(`UPDATE cards SET balance = balance + ? WHERE card_number = ?`, baseAmount, cardNumber); err != nil { - return err - } + // Mark the transaction ledger as completed. We find the related pending transaction by card_number, amount, and status. + // We use LIMIT 1 to ensure we only update one pending transaction if there are duplicates. + if _, err := tx.Exec(`UPDATE transactions SET status = 'completed', description = 'Successful topup via Xendit' WHERE card_number = ? AND transaction_type = 'topup' AND status = 'pending' AND amount = ? ORDER BY created_at DESC LIMIT 1`, cardNumber, amount); err != nil { + log.Println("Failed to update transactions status:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } - // Insert into Loading Ledger (top_ups table) - // top_ups table is our loading ledger - queryTopUp := `INSERT INTO top_ups (topup_id, card_number, amount, convenience_fee, gateway_cost, payment_method, handled_by, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - if _, err := tx.Exec(queryTopUp, topupID, cardNumber, baseAmount, convenienceFee, paymentGatewayCost, paymentMethod, "payment gateway", externalID); err != nil { - return err - } + if err := tx.Commit(); err != nil { + log.Println("Failed to commit transaction:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } - // Insert into Spending Ledger (transactions table) - // transactions table is our spending ledger - queryTx := `INSERT INTO transactions (transaction_id, card_number, merchant_id, terminal_id, transaction_type, amount, service_fee, processed_by, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - if _, err := tx.Exec(queryTx, transactionID, cardNumber, "xendit", "xendit", "topup", baseAmount, convenienceFee, "xendit", "Successful topup via Xendit"); err != nil { - return err + log.Printf("Successfully loaded ₱%.2f onto card %s via Xendit", amount, cardNumber) + + // log if payment failed, expired, or canceled + case "EXPIRED", "FAILED", "PENDING", "CANCELED": + log.Printf("Payment failed or pending for external ID: %s, status: %s", payload.ExternalID, payload.Status) + if payload.Status == "EXPIRED" || payload.Status == "FAILED" || payload.Status == "CANCELED" { + // Update the database records to failed so users see it as failed + _, _ = h.DB.Exec(`UPDATE top_ups SET status = 'failed' WHERE topup_id = ? AND status = 'pending'`, payload.ExternalID) + + var cardNumber string + var amount float64 + if err := h.DB.QueryRow(`SELECT card_number, amount FROM top_ups WHERE topup_id = ?`, payload.ExternalID).Scan(&cardNumber, &amount); err == nil { + _, _ = h.DB.Exec(`UPDATE transactions SET status = 'failed', description = 'Failed topup via Xendit' WHERE card_number = ? AND transaction_type = 'topup' AND status = 'pending' AND amount = ? ORDER BY created_at DESC LIMIT 1`, cardNumber, amount) + } + } } - // Commit the transaction if everything is fine - return tx.Commit() + // Always 200 OK to Xendit + w.WriteHeader(http.StatusOK) } diff --git a/backend/pkg/databases/db.go b/backend/pkg/databases/db.go deleted file mode 100644 index c02c0dc..0000000 --- a/backend/pkg/databases/db.go +++ /dev/null @@ -1,54 +0,0 @@ -// DB connection setup -package db - -import ( - "database/sql" - "fmt" - "html/template" - "log" - "os" - - "github.com/joho/godotenv" -) - -var tpl *template.Template -var db *sql.DB - -func DBSetup() { - // Load .env file - err := godotenv.Load("../.env") - if err != nil { - // Fallback: try loading from current directory - if err := godotenv.Load(); err != nil { - log.Fatalf("Error loading .env file: %v", err) - } - } - - // read .env VALUES - //port := os.Getenv("PORT") - //serverAddress := os.Getenv("SERVER_ADDR") - dbUser := os.Getenv("DB_USER") - dbPass := os.Getenv("DB_PASSWORD") - dbName := os.Getenv("DB_NAME") - dbHost := os.Getenv("DB_HOST") - dbPort := os.Getenv("DB_PORT") - dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPass, dbHost, dbPort, dbName) - - // Setup Templates - tpl, err = template.ParseGlob("../templates/*.html") - if err != nil { - log.Fatal("Templates loaded but variable is nil. Check your folder path.") - } - - // Setup Database - db, err = sql.Open("mysql", dsn) - if err != nil { - panic(err.Error()) - } - defer db.Close() - - // Always verify connection - if err := db.Ping(); err != nil { - panic("Database connection failed: " + err.Error()) - } -} diff --git a/docs/unicard.sql b/docs/unicard.sql index 221349a..6e4f29f 100644 --- a/docs/unicard.sql +++ b/docs/unicard.sql @@ -45,6 +45,8 @@ CREATE TABLE merchants ( 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', + city VARCHAR(100) NULL COMMENT 'City or municipality where the primary business is physically located', + postal_code VARCHAR(20) NULL COMMENT 'Postal or ZIP code of the primary business address location', 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', @@ -53,14 +55,11 @@ CREATE TABLE merchants ( 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', + status ENUM('pending approval', 'approved', 'rejected', 'active', 'suspended', 'deleted') DEFAULT 'pending approval' COMMENT 'Operational state of the merchant ecosystem tenancy', + business_document VARCHAR(255) NULL COMMENT 'Primary business registration document path (DTI or SEC depending on business_structure)', 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', - business_document VARCHAR(255) NULL COMMENT 'Primary business registration document path (DTI or SEC depending on business_structure)', business_structure ENUM('sole_proprietorship', 'partnership', 'corporation', 'cooperative') NULL COMMENT 'Legal structure of the business entity used to determine required registration documents', - city VARCHAR(100) NULL COMMENT 'City or municipality where the primary business is physically located', - postal_code VARCHAR(20) NULL COMMENT 'Postal or ZIP code of the primary business address location', document_status ENUM('pending', 'approved', 'rejected') DEFAULT 'pending' COMMENT 'Verification state of the submitted business registration and compliance documents', message TEXT NULL COMMENT 'Admin-written note or feedback message regarding document review or account status changes', approved_by VARCHAR(50) NULL COMMENT 'The user_id of the Super Admin who verified and activated this company profile', @@ -116,9 +115,9 @@ CREATE TABLE transactions ( 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', 'withdrawal') 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', - points_earned DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Total points earned from the transaction', - service_fee DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Platform revenue slice collected by UniCard ecosystem engine per tap processing action', + amount DECIMAL(10, 2) NULL COMMENT 'Total Gross fiat amount captured from the card wallet balance tracking column', + points_earned DECIMAL(10, 2) NULL DEFAULT NULL COMMENT 'Total points earned from the transaction', + service_fee DECIMAL(10, 2) NULL DEFAULT NULL 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', diff --git a/frontend/assets/admin/merchant_info.js b/frontend/assets/admin/merchant_info.js index c531643..b052243 100644 --- a/frontend/assets/admin/merchant_info.js +++ b/frontend/assets/admin/merchant_info.js @@ -51,6 +51,8 @@ function populateMerchantUI(merchant) { document.getElementById('businessType').textContent = merchant.BusinessType.replace(/_/g, ' '); document.getElementById('registrationNum').textContent = merchant.RegistrationNum || 'N/A'; document.getElementById('businessAddress').textContent = merchant.BusinessAddress; + document.getElementById('businessCity').textContent = merchant.City || 'N/A'; + document.getElementById('businessPostalCode').textContent = merchant.PostalCode || 'N/A'; document.getElementById('createdAt').textContent = new Date(merchant.CreatedAt).toLocaleDateString(); document.getElementById('ownerName').textContent = merchant.OwnerName; @@ -60,7 +62,8 @@ function populateMerchantUI(merchant) { const renderDoc = (url, elId) => { const el = document.getElementById(elId); if (url) { - el.innerHTML = ``; @@ -69,7 +72,7 @@ function populateMerchantUI(merchant) { } }; - renderDoc(merchant.DtiDocument, 'dtiDocumentLink'); + renderDoc(merchant.BusinessDocument, 'businessDocumentLink'); renderDoc(merchant.BirDocument, 'birDocumentLink'); renderDoc(merchant.OtherDocument, 'otherDocumentLink'); @@ -109,7 +112,7 @@ function populateMerchantUI(merchant) { btnApprove.classList.add('hidden'); btnReject.classList.add('hidden'); btnDelete.classList.remove('hidden'); - } else if (statusLower === 'pending_approval' || statusLower === 'pending approval') { + } else if (statusLower === 'pending_approval' || statusLower === 'pending approval' || (statusLower === 'rejected' && merchant.DocumentStatus && merchant.DocumentStatus.toLowerCase() === 'pending')) { btnSuspend.classList.add('hidden'); btnApprove.classList.remove('hidden'); btnReject.classList.remove('hidden'); @@ -122,19 +125,97 @@ function populateMerchantUI(merchant) { } } +let currentZoom = 1; +let isDragging = false; +let startX = 0, startY = 0, translateX = 0, translateY = 0; + window.openDocumentViewer = function(url) { const modal = document.getElementById('documentViewerModal'); const iframe = document.getElementById('documentViewerFrame'); - const downloadBtn = document.getElementById('downloadDocumentBtn'); + const img = document.getElementById('documentViewerImage'); + const zoomControls = document.getElementById('imageZoomControls'); - if (modal && iframe && downloadBtn) { - iframe.src = url; - downloadBtn.href = url; + if (modal && iframe && img) { + const isImage = url.match(/\.(jpeg|jpg|gif|png|webp)$/i); + if (isImage) { + iframe.classList.add('hidden'); + img.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.remove('hidden'); + img.src = url; + resetZoom(); + } else { + img.classList.add('hidden'); + iframe.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.add('hidden'); + iframe.src = url + '#toolbar=0&navpanes=0&scrollbar=0'; + } modal.classList.remove('hidden'); } }; +function resetZoom() { + currentZoom = 1; + translateX = 0; + translateY = 0; + updateZoomTransform(); +} + +function updateZoomTransform() { + const img = document.getElementById('documentViewerImage'); + const zoomLevelEl = document.getElementById('zoomLevel'); + if (img) { + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentZoom})`; + } + if (zoomLevelEl) { + zoomLevelEl.textContent = Math.round(currentZoom * 100) + '%'; + } +} + document.addEventListener('DOMContentLoaded', () => { + // Zoom Controls + const zoomInBtn = document.getElementById('zoomInBtn'); + const zoomOutBtn = document.getElementById('zoomOutBtn'); + const zoomResetBtn = document.getElementById('zoomResetBtn'); + const img = document.getElementById('documentViewerImage'); + + if (zoomInBtn) zoomInBtn.addEventListener('click', () => { currentZoom = Math.min(currentZoom + 0.25, 4); updateZoomTransform(); }); + if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => { currentZoom = Math.max(currentZoom - 0.25, 0.5); updateZoomTransform(); }); + if (zoomResetBtn) zoomResetBtn.addEventListener('click', resetZoom); + + if (img) { + img.parentElement.addEventListener('wheel', (e) => { + if (img.classList.contains('hidden')) return; + e.preventDefault(); + if (e.deltaY < 0) { + currentZoom = Math.min(currentZoom + 0.1, 4); + } else { + currentZoom = Math.max(currentZoom - 0.1, 0.5); + } + updateZoomTransform(); + }); + + // Drag to pan + img.parentElement.addEventListener('mousedown', (e) => { + if (img.classList.contains('hidden')) return; + isDragging = true; + img.parentElement.style.cursor = 'grabbing'; + startX = e.clientX - translateX; + startY = e.clientY - translateY; + }); + + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + translateX = e.clientX - startX; + translateY = e.clientY - startY; + updateZoomTransform(); + }); + + window.addEventListener('mouseup', () => { + isDragging = false; + if (img) img.parentElement.style.cursor = 'default'; + }); + } + fetchUnassignedTerminals(); fetchMerchantData(); diff --git a/frontend/assets/js/merchant_account.js b/frontend/assets/js/merchant_account.js index c450c80..ad775e9 100644 --- a/frontend/assets/js/merchant_account.js +++ b/frontend/assets/js/merchant_account.js @@ -28,6 +28,23 @@ document.addEventListener("DOMContentLoaded", () => { statusEl.innerHTML = `${data.account_status}`; } + // Document Status and Message + const docStatusEl = document.getElementById('displayDocumentStatus'); + if (docStatusEl && data.document_status) { + const ds = data.document_status.toLowerCase(); + let dsColor = 'text-amber-600 bg-amber-100'; + if (ds === 'approved') dsColor = 'text-emerald-600 bg-emerald-100'; + else if (ds === 'rejected') dsColor = 'text-red-600 bg-red-100'; + docStatusEl.innerHTML = `Docs: ${data.document_status}`; + } + + const msgContainer = document.getElementById('docMessageContainer'); + const msgEl = document.getElementById('displayAccountMessage'); + if (msgContainer && msgEl && data.account_message && data.account_message.trim() !== '') { + msgEl.textContent = data.account_message; + msgContainer.classList.remove('hidden'); + } + // Business Details Form if (document.getElementById('bizName')) document.getElementById('bizName').value = details.business_name || ''; if (document.getElementById('bizType')) document.getElementById('bizType').value = details.business_type || ''; @@ -40,52 +57,459 @@ document.addEventListener("DOMContentLoaded", () => { // Bank Details if (document.getElementById('bankName')) document.getElementById('bankName').value = bank.bank_name || ''; if (document.getElementById('bankHolder')) document.getElementById('bankHolder').value = bank.account_holder_name || ''; - if (document.getElementById('bankAccount')) document.getElementById('bankAccount').value = bank.account_number || ''; + + let realAccountNumber = bank.account_number || ''; + let maskedAccountNumber = realAccountNumber; + if (realAccountNumber.length > 4) { + maskedAccountNumber = "**** **** **** " + realAccountNumber.slice(-4); + } + + const bankInput = document.getElementById('bankAccount'); + if (bankInput) { + bankInput.value = maskedAccountNumber; + bankInput.dataset.realValue = realAccountNumber; + } + + const actionContainer = document.getElementById('bankDetailsActionContainer'); + const editBtn = document.getElementById('editBankDetailsBtn'); + const saveBtn = document.getElementById('saveBankDetailsBtn'); + const cancelBtn = document.getElementById('bankDetailsCancelBtn'); + if (actionContainer) actionContainer.classList.remove('hidden'); + + const initialBankDetails = { + bankName: bank.bank_name || '', + bankHolder: bank.account_holder_name || '', + bankAccount: bank.account_number || '' + }; + + // Toggle Account Number visibility + const toggleBtn = document.getElementById('toggleBankAccount'); + const eyeOpen = document.getElementById('eyeIconOpen'); + const eyeClosed = document.getElementById('eyeIconClosed'); + let isAccountMasked = true; + + if (toggleBtn && bankInput && eyeOpen && eyeClosed) { + toggleBtn.addEventListener('click', () => { + if (isAccountMasked) { + // Show real number + bankInput.value = bankInput.dataset.realValue || ''; + eyeOpen.classList.remove('hidden'); + eyeClosed.classList.add('hidden'); + isAccountMasked = false; + } else { + // Mask the number + let currentVal = bankInput.value; + let masked = currentVal; + if (currentVal.length > 4) { + masked = "**** **** **** " + currentVal.slice(-4); + } + bankInput.value = masked; + eyeOpen.classList.add('hidden'); + eyeClosed.classList.remove('hidden'); + isAccountMasked = true; + } + }); + + // Update realValue as they type when unmasked + bankInput.addEventListener('input', (e) => { + if (!isAccountMasked) { + bankInput.dataset.realValue = e.target.value; + checkEnableBankSave(); + } + }); + } + + function checkEnableBankSave() { + if (!saveBtn) return; + const currentAccountVal = isAccountMasked ? bankInput.dataset.realValue : document.getElementById('bankAccount').value; + const current = { + bankName: document.getElementById('bankName').value, + bankHolder: document.getElementById('bankHolder').value, + bankAccount: currentAccountVal + }; + const isChanged = current.bankName !== initialBankDetails.bankName || + current.bankHolder !== initialBankDetails.bankHolder || + current.bankAccount !== initialBankDetails.bankAccount; + if (isChanged) { + saveBtn.disabled = false; + saveBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + } else { + saveBtn.disabled = true; + saveBtn.classList.add('opacity-50', 'cursor-not-allowed'); + } + } + + if (editBtn) { + editBtn.addEventListener('click', () => { + ['bankName', 'bankHolder', 'bankAccount'].forEach(id => { + const el = document.getElementById(id); + if (el) { + el.removeAttribute('readonly'); + el.removeAttribute('disabled'); + el.classList.remove('bg-gray-50', 'text-gray-600', 'cursor-not-allowed'); + el.classList.add('bg-white', 'text-gray-900', 'ring-2', 'ring-indigo-100'); + // Only cursor-pointer for non-text inputs, but here select needs it + if (el.tagName === 'SELECT') el.classList.add('cursor-pointer'); + if (id !== 'bankAccount') { + el.addEventListener('input', checkEnableBankSave); + } + el.addEventListener('change', checkEnableBankSave); + } + }); + + // Force unmask when editing begins + if (isAccountMasked && toggleBtn) { + toggleBtn.click(); + } + + editBtn.classList.add('hidden'); + saveBtn.classList.remove('hidden'); + if (cancelBtn) cancelBtn.classList.remove('hidden'); + checkEnableBankSave(); + }); + } + if (cancelBtn) { + cancelBtn.addEventListener('click', () => window.location.reload()); + } + + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + // Reset errors + const errorDiv = document.getElementById('bankDetailsError'); + if (errorDiv) errorDiv.classList.add('hidden'); + ['bankName', 'bankHolder', 'bankAccount'].forEach(id => { + const el = document.getElementById(id); + if (el) { + el.classList.remove('ring-red-500', 'border-red-500'); + } + }); + + const finalAccountNumber = isAccountMasked ? bankInput.dataset.realValue : document.getElementById('bankAccount').value; + const newBank = { + bank_name: document.getElementById('bankName').value, + account_holder_name: document.getElementById('bankHolder').value, + account_number: finalAccountNumber + }; + + let hasError = false; + if (!newBank.bank_name) { + document.getElementById('bankName').classList.add('ring-red-500', 'border-red-500'); + hasError = true; + } + if (!newBank.account_holder_name) { + document.getElementById('bankHolder').classList.add('ring-red-500', 'border-red-500'); + hasError = true; + } + if (!newBank.account_number) { + document.getElementById('bankAccount').classList.add('ring-red-500', 'border-red-500'); + hasError = true; + } + + if (hasError) { + if (errorDiv) { + errorDiv.textContent = 'Please fill in all required fields marked with *'; + errorDiv.classList.remove('hidden'); + } + return; + } + + saveBtn.textContent = 'Saving...'; + saveBtn.disabled = true; + try { + const res = await fetch(`/v1/merchant/${window.CURRENT_USERNAME}/update-bank`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newBank) + }); + const j = await res.json(); + if (j.success) { + alert('Bank details updated successfully!'); + window.location.reload(); + } else { + alert('Failed: ' + j.message); + saveBtn.textContent = 'Save Changes'; + saveBtn.disabled = false; + } + } catch (e) { + console.error(e); + alert('Network error'); + saveBtn.textContent = 'Save Changes'; + saveBtn.disabled = false; + } + }); + } // Documents const docsContainer = document.getElementById('documentsContainer'); if (docsContainer) { - if (docs.length === 0) { - docsContainer.innerHTML = '

No documents uploaded.

'; - } else { - docsContainer.innerHTML = ''; - docs.forEach(doc => { - const isApproved = doc.status.toLowerCase() === 'approved'; - const isPending = doc.status.toLowerCase() === 'pending'; - let statusColor = 'text-red-600 bg-red-50'; - let icon = ''; - if (isApproved) { - statusColor = 'text-emerald-600 bg-emerald-50'; - icon = ''; - } else if (isPending) { - statusColor = 'text-amber-600 bg-amber-50'; - icon = ''; - } + docsContainer.innerHTML = ''; + + const structure = details.business_structure || ''; + const regLabel = 'DTI/SEC Registration'; + + const expectedDocs = [ + { type: regLabel, key: 'business_document' }, + { type: 'BIR Certificate', key: 'bir_document' }, + { type: 'Other Document', key: 'other_document' } + ]; + + expectedDocs.forEach(ed => { + // Find if merchant has uploaded this doc type + const found = docs.find(d => d.document_type === ed.type); + + let docType = ed.type; + let status = found ? (found.document_status || 'Pending') : 'Missing'; + let message = found ? found.message : 'Please upload this document'; + let docUrl = found ? found.document_url : ''; + + const isApproved = status.toLowerCase() === 'approved'; + const isPending = status.toLowerCase() === 'pending'; + const isMissing = status.toLowerCase() === 'missing'; + + let statusColor = 'text-red-600 bg-red-50'; + let icon = ''; + if (isApproved) { + statusColor = 'text-emerald-600 bg-emerald-50'; + icon = ''; + } else if (isPending) { + statusColor = 'text-amber-600 bg-amber-50'; + icon = ''; + } - const docDiv = document.createElement('div'); - docDiv.className = 'flex items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50/50 hover:bg-gray-50 transition-colors'; - docDiv.innerHTML = ` -
-
- -
-
-

${doc.document_type}

- ${doc.message ? `

${doc.message}

` : ''} -
+ const isImage = docUrl && docUrl.match(/\.(jpeg|jpg|gif|png|webp)$/i); + const defaultIconHtml = ``; + const previewHtml = isImage ? `${docType} Preview` : defaultIconHtml; + + const docDiv = document.createElement('div'); + docDiv.className = 'flex items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50/50 hover:bg-gray-50 transition-colors'; + docDiv.innerHTML = ` +
+
+ ${previewHtml}
-
- - ${icon} - ${doc.status} - - ${doc.document_url ? `` : ''} +
+

${docType}

+ ${message ? `

${message}

` : ''}
- `; - docsContainer.appendChild(docDiv); - }); +
+
+ + ${icon} + ${status} + + ${docUrl ? `` : ''} + ${!isApproved ? ` +
+ +
+ ` : ''} +
+ `; + docsContainer.appendChild(docDiv); + }); + } + + // Store actual selected files securely so native cancel doesn't erase them + const selectedFilesMap = new Map(); + const globalDocBtn = document.getElementById('globalDocUploadBtn'); + let isDocEditMode = false; + + function checkEnableSave() { + const hasFiles = selectedFilesMap.size > 0; + if (globalDocBtn) { + globalDocBtn.disabled = !hasFiles; + if (hasFiles) { + globalDocBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + globalDocBtn.classList.add('hover:bg-indigo-700'); + } else { + globalDocBtn.classList.add('opacity-50', 'cursor-not-allowed'); + globalDocBtn.classList.remove('hover:bg-indigo-700'); + } } } + + const globalDocCancelBtn = document.getElementById('globalDocCancelBtn'); + + if (globalDocCancelBtn) { + globalDocCancelBtn.addEventListener('click', () => window.location.reload()); + } + + if (globalDocBtn) { + globalDocBtn.addEventListener('click', async () => { + if (!isDocEditMode) { + // Enter edit mode + isDocEditMode = true; + globalDocBtn.textContent = 'Save Changes'; + globalDocBtn.classList.remove('bg-gray-100', 'text-gray-700', 'hover:bg-gray-200'); + globalDocBtn.classList.add('bg-indigo-600', 'text-white', 'opacity-50', 'cursor-not-allowed'); + globalDocBtn.disabled = true; + + if (globalDocCancelBtn) globalDocCancelBtn.classList.remove('hidden'); + + // Enable dropzones + document.querySelectorAll('.doc-dropzone').forEach(dz => { + dz.classList.remove('pointer-events-none', 'opacity-60'); + dz.classList.add('cursor-pointer', 'hover:bg-slate-100'); + const input = dz.querySelector('input'); + if (input) input.disabled = false; + }); + } else { + // Save Changes mode (Upload) + if (selectedFilesMap.size === 0) return; + + globalDocBtn.textContent = 'Uploading...'; + globalDocBtn.disabled = true; + globalDocBtn.classList.add('opacity-70'); + + let successCount = 0; + let failCount = 0; + + for (const [docType, file] of selectedFilesMap.entries()) { + const formData = new FormData(); + formData.append('document', file); + formData.append('document_type', docType); + + try { + const res = await fetch(`/v1/merchant/${window.CURRENT_USERNAME}/upload-document`, { + method: 'POST', + body: formData + }); + const j = await res.json(); + if (j.success) successCount++; + else { + failCount++; + console.error(j.message); + } + } catch (err) { + console.error(err); + failCount++; + } + } + + if (failCount === 0) { + alert('Documents uploaded successfully!'); + } else { + alert(`Uploaded ${successCount} documents. Failed ${failCount}.`); + } + window.location.reload(); + } + }); + } + + document.querySelectorAll('.doc-dropzone').forEach(dropzone => { + const input = dropzone.querySelector('input'); + const text = dropzone.querySelector('.dropzone-text'); + const hint = dropzone.querySelector('.dropzone-hint'); + const svgIcon = dropzone.querySelector('svg'); + + const originalSvg = ''; + const successSvg = ''; + + function resetDropzone() { + text.textContent = 'Click or drag & drop'; + text.classList.remove('text-emerald-600', 'font-bold'); + hint.textContent = 'PDF, JPG, PNG (Max 4MB)'; + hint.classList.remove('text-emerald-500', 'font-medium'); + if(svgIcon) { + svgIcon.innerHTML = originalSvg; + svgIcon.classList.remove('text-emerald-500'); + svgIcon.classList.add('text-slate-400'); + } + dropzone.classList.remove('border-indigo-400', 'bg-indigo-50', 'border-emerald-400', 'bg-emerald-50'); + } + + function setSuccessDropzone(filename) { + text.textContent = filename; + text.title = filename; + text.classList.add('text-emerald-600', 'font-bold'); + hint.textContent = 'Ready to save'; + hint.classList.add('text-emerald-500', 'font-medium'); + if(svgIcon) { + svgIcon.innerHTML = successSvg; + svgIcon.classList.remove('text-slate-400'); + svgIcon.classList.add('text-emerald-500'); + } + dropzone.classList.remove('border-indigo-400', 'bg-indigo-50'); + dropzone.classList.add('border-emerald-400', 'bg-emerald-50'); + } + + dropzone.addEventListener('dragover', (e) => { + e.preventDefault(); + if (input && !input.disabled) { + dropzone.classList.add('bg-indigo-50', 'border-indigo-300'); + } + }); + + dropzone.addEventListener('dragleave', (e) => { + e.preventDefault(); + if (input && !input.disabled && !selectedFilesMap.has(input.dataset.doctype)) { + dropzone.classList.remove('bg-indigo-50', 'border-indigo-300'); + } + }); + + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + if (input && !input.disabled && e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0]; + processFile(file, input.dataset.doctype); + } else if (!selectedFilesMap.has(input.dataset.doctype)) { + dropzone.classList.remove('bg-indigo-50', 'border-indigo-300'); + } + }); + + if (input) { + input.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) { + // Ignore native cancel event to preserve previously stored file + return; + } + processFile(file, input.dataset.doctype); + // Reset input so the same file can trigger a change event again if needed + e.target.value = ''; + }); + } + + function updatePreview(file) { + const previewContainer = dropzone.closest('.flex.items-center.justify-between').querySelector('.doc-preview'); + if (previewContainer) { + if (file && file.type.startsWith('image/')) { + const url = URL.createObjectURL(file); + previewContainer.innerHTML = `New Preview`; + } else { + previewContainer.innerHTML = ``; + } + } + } + + function processFile(file, docType) { + const maxSize = 4 * 1024 * 1024; + if (file.size > maxSize) { + alert('File is too large. Please upload a file smaller than 4MB.'); + if (!selectedFilesMap.has(docType)) resetDropzone(); + return; + } + + const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; + if (!validTypes.includes(file.type)) { + alert('Invalid file format. Please upload an image, PDF, or Word document.'); + if (!selectedFilesMap.has(docType)) resetDropzone(); + return; + } + + selectedFilesMap.set(docType, file); + setSuccessDropzone(file.name); + checkEnableSave(); + updatePreview(file); + } + }); // Remove all animate-pulse classes to reveal inputs document.querySelectorAll('.animate-pulse').forEach(el => { @@ -101,18 +525,109 @@ document.addEventListener("DOMContentLoaded", () => { formContent.classList.remove('opacity-0'); formContent.classList.add('opacity-100'); } - const loadingSkeleton = document.getElementById('loadingSkeleton'); - if (loadingSkeleton) { - loadingSkeleton.style.display = 'none'; - } - } else { console.error("Failed to fetch account data:", json.message); } } catch (error) { console.error("Error fetching account data:", error); + } finally { + const loadingSkeleton = document.getElementById('loadingSkeleton'); + if (loadingSkeleton) { + loadingSkeleton.style.display = 'none'; + } } }; fetchAccountData(); }); + +let currentZoom = 1; +let isDragging = false; +let startX = 0, startY = 0, translateX = 0, translateY = 0; + +window.openDocumentViewer = function(url) { + const modal = document.getElementById('documentViewerModal'); + const iframe = document.getElementById('documentViewerFrame'); + const img = document.getElementById('documentViewerImage'); + const zoomControls = document.getElementById('imageZoomControls'); + + if (modal && iframe && img) { + const isImage = url.match(/\.(jpeg|jpg|gif|png|webp)$/i); + if (isImage) { + iframe.classList.add('hidden'); + img.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.remove('hidden'); + img.src = url; + resetZoom(); + } else { + img.classList.add('hidden'); + iframe.classList.remove('hidden'); + if (zoomControls) zoomControls.classList.add('hidden'); + iframe.src = url + '#toolbar=0&navpanes=0&scrollbar=0'; + } + modal.classList.remove('hidden'); + } +}; + +function resetZoom() { + currentZoom = 1; + translateX = 0; + translateY = 0; + updateZoomTransform(); +} + +function updateZoomTransform() { + const img = document.getElementById('documentViewerImage'); + const zoomLevelEl = document.getElementById('zoomLevel'); + if (img) { + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentZoom})`; + } + if (zoomLevelEl) { + zoomLevelEl.textContent = Math.round(currentZoom * 100) + '%'; + } +} + +document.addEventListener('DOMContentLoaded', () => { + // Zoom Controls setup (will attach if elements exist) + const zoomInBtn = document.getElementById('zoomInBtn'); + const zoomOutBtn = document.getElementById('zoomOutBtn'); + const zoomResetBtn = document.getElementById('zoomResetBtn'); + const img = document.getElementById('documentViewerImage'); + + if (zoomInBtn) zoomInBtn.addEventListener('click', () => { currentZoom = Math.min(currentZoom + 0.25, 4); updateZoomTransform(); }); + if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => { currentZoom = Math.max(currentZoom - 0.25, 0.5); updateZoomTransform(); }); + if (zoomResetBtn) zoomResetBtn.addEventListener('click', resetZoom); + + if (img) { + img.parentElement.addEventListener('wheel', (e) => { + if (img.classList.contains('hidden')) return; + e.preventDefault(); + if (e.deltaY < 0) { + currentZoom = Math.min(currentZoom + 0.1, 4); + } else { + currentZoom = Math.max(currentZoom - 0.1, 0.5); + } + updateZoomTransform(); + }); + + img.parentElement.addEventListener('mousedown', (e) => { + if (img.classList.contains('hidden')) return; + isDragging = true; + img.parentElement.style.cursor = 'grabbing'; + startX = e.clientX - translateX; + startY = e.clientY - translateY; + }); + + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + translateX = e.clientX - startX; + translateY = e.clientY - startY; + updateZoomTransform(); + }); + + window.addEventListener('mouseup', () => { + isDragging = false; + if (img) img.parentElement.style.cursor = 'default'; + }); + } +}); diff --git a/frontend/assets/js/merchant_dashboard.js b/frontend/assets/js/merchant_dashboard.js index 857a001..c719e59 100644 --- a/frontend/assets/js/merchant_dashboard.js +++ b/frontend/assets/js/merchant_dashboard.js @@ -192,6 +192,35 @@ document.addEventListener("DOMContentLoaded", () => { netAmtEl.textContent = `${sign}₱${netValue.toFixed(2)}`; netAmtEl.className = `font-bold text-lg ${colorClass}`; + const isSystemEvent = grossAmt === 0 && Number(tx.service_fee || 0) === 0; + + if (isSystemEvent) { + let sysType = "System Notification"; + if (tx.transaction_id && tx.transaction_id.toLowerCase().startsWith("welcome")) { + sysType = "Account Approval"; + } else if (tx.description && tx.description.toLowerCase().includes("username")) { + sysType = "Profile Update"; + } else if (tx.description && tx.description.toLowerCase().includes("settlement")) { + sysType = "Bank Update"; + } else if (tx.transaction_type) { + sysType = tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1); + } + + document.getElementById("modalTxnType").textContent = sysType; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.add("hidden"); + } else { + document.getElementById("modalTxnType").textContent = tx.transaction_type ? tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1) : "N/A"; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.remove("hidden"); + } + txnModal.classList.remove('hidden'); setTimeout(() => { txnModal.classList.add('opacity-100'); diff --git a/frontend/assets/js/merchant_signup.js b/frontend/assets/js/merchant_signup.js index e72b40d..264a78b 100644 --- a/frontend/assets/js/merchant_signup.js +++ b/frontend/assets/js/merchant_signup.js @@ -45,11 +45,11 @@ document.addEventListener("DOMContentLoaded", () => { reader.onerror = error => reject(error); }); - const dtiFile = document.getElementById("dtiDocument").files[0]; + const bizDocFile = document.getElementById("businessDocument").files[0]; const birFile = document.getElementById("birDocument").files[0]; const otherFile = document.getElementById("otherDocument").files[0]; - const dtiDocument = await toBase64(dtiFile); + const businessDocument = await toBase64(bizDocFile); const birDocument = await toBase64(birFile); const otherDocument = await toBase64(otherFile); @@ -62,7 +62,7 @@ document.addEventListener("DOMContentLoaded", () => { businessPhone, businessEmail, password, - dtiDocument, + businessDocument, birDocument, otherDocument }; diff --git a/frontend/assets/js/merchant_transactions.js b/frontend/assets/js/merchant_transactions.js index 4294bb8..8218367 100644 --- a/frontend/assets/js/merchant_transactions.js +++ b/frontend/assets/js/merchant_transactions.js @@ -204,6 +204,35 @@ document.addEventListener("DOMContentLoaded", () => { netAmtEl.textContent = `${sign}${formatCurrency(Math.abs(netValue))}`; netAmtEl.className = `font-bold text-lg ${colorClass}`; + const isSystemEvent = grossAmt === 0 && Number(tx.service_fee || 0) === 0; + + if (isSystemEvent) { + let sysType = "System Notification"; + if (tx.transaction_id && tx.transaction_id.toLowerCase().startsWith("welcome")) { + sysType = "Account Approval"; + } else if (tx.description && tx.description.toLowerCase().includes("username")) { + sysType = "Profile Update"; + } else if (tx.description && tx.description.toLowerCase().includes("settlement")) { + sysType = "Bank Update"; + } else if (tx.transaction_type) { + sysType = tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1); + } + + document.getElementById("modalTxnType").textContent = sysType; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.add("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.add("hidden"); + } else { + document.getElementById("modalTxnType").textContent = tx.transaction_type ? tx.transaction_type.charAt(0).toUpperCase() + tx.transaction_type.slice(1) : "N/A"; + document.getElementById("modalTxnCardNumber").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnStatus").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnGross").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnFee").closest('.flex').classList.remove("hidden"); + document.getElementById("modalTxnNet").closest('.flex').classList.remove("hidden"); + } + txnModal.classList.remove('hidden'); setTimeout(() => { txnModal.classList.add('opacity-100'); diff --git a/frontend/templates/admin/addCards.html b/frontend/templates/admin/addCards.html index 16ff446..fe77f22 100644 --- a/frontend/templates/admin/addCards.html +++ b/frontend/templates/admin/addCards.html @@ -7,12 +7,11 @@ UniCard Admin - Add Card - + - - - {{template "admin_sidebar" .}} + +
+ + {{template "admin_sidebar" .}} - -
+ +
@@ -173,7 +173,7 @@

UniCard

- +
diff --git a/frontend/templates/admin/admin_sidebar.html b/frontend/templates/admin/admin_sidebar.html index 4f89d5b..8ce39a3 100644 --- a/frontend/templates/admin/admin_sidebar.html +++ b/frontend/templates/admin/admin_sidebar.html @@ -1,25 +1,27 @@ {{define "admin_sidebar"}} -