package main import ( "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "math/big" "net/http" "os" "strconv" "strings" "time" "github.com/golang-jwt/jwt/v5" _ "github.com/lib/pq" "github.com/stripe/stripe-go/v81" "github.com/stripe/stripe-go/v81/paymentintent" "github.com/stripe/stripe-go/v81/webhook" ) // Request size limit const maxRequestBody = 1 << 20 // 1MB max request body size // Safe error response helper - logs detailed error but returns generic message to client func safeError(w http.ResponseWriter, logMsg string, err error, statusCode int) { log.Printf("ERROR: %s: %v", logMsg, err) switch statusCode { case http.StatusBadRequest: http.Error(w, "Invalid request", http.StatusBadRequest) case http.StatusUnauthorized: http.Error(w, "Unauthorized", http.StatusUnauthorized) case http.StatusForbidden: http.Error(w, "Access denied", http.StatusForbidden) case http.StatusNotFound: http.Error(w, "Resource not found", http.StatusNotFound) case http.StatusConflict: http.Error(w, "Resource conflict", http.StatusConflict) case http.StatusServiceUnavailable: http.Error(w, "Service temporarily unavailable", http.StatusServiceUnavailable) default: http.Error(w, "Internal server error", http.StatusInternalServerError) } } // safeErrorMsg returns a safe error with a custom client-visible message func safeErrorMsg(w http.ResponseWriter, logMsg string, err error, clientMsg string, statusCode int) { log.Printf("ERROR: %s: %v", logMsg, err) http.Error(w, clientMsg, statusCode) } // Invoice represents an invoice in the system. type Invoice struct { ID int `json:"id"` InvoiceNumber string `json:"invoiceNumber"` ProjectID *int `json:"projectId"` ClientID int `json:"clientId"` Amount float64 `json:"amount"` Currency string `json:"currency"` Status string `json:"status"` DueDate *string `json:"dueDate"` IssuedDate *string `json:"issuedDate"` PaidDate *string `json:"paidDate"` BlockchainTxHash *string `json:"blockchainTxHash"` IPFSDocumentCID *string `json:"ipfsDocumentCid"` Notes string `json:"notes"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // InvoiceItem represents a line item on an invoice. type InvoiceItem struct { ID int `json:"id"` InvoiceID int `json:"invoiceId"` Description string `json:"description"` Quantity float64 `json:"quantity"` UnitPrice float64 `json:"unitPrice"` TaxRate float64 `json:"taxRate"` Total float64 `json:"total"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // Payment represents a payment transaction. type Payment struct { ID int `json:"id"` InvoiceID int `json:"invoiceId"` Amount float64 `json:"amount"` Currency string `json:"currency"` PaymentMethod string `json:"paymentMethod"` Status string `json:"status"` TransactionID *string `json:"transactionId"` BlockchainTxHash *string `json:"blockchainTxHash"` BlockchainNetwork *string `json:"blockchainNetwork"` PaymentProcessor *string `json:"paymentProcessor"` ProcessorFee *float64 `json:"processorFee"` ProcessedAt *time.Time `json:"processedAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // CreatePaymentIntentRequest for Stripe payment intent creation. type CreatePaymentIntentRequest struct { InvoiceID int `json:"invoiceId"` } // CreatePaymentIntentResponse contains the client secret for Stripe. type CreatePaymentIntentResponse struct { ClientSecret string `json:"clientSecret"` PaymentID int `json:"paymentId"` } // ===== CRYPTO PAYMENT TYPES ===== // PaymentNetwork represents a blockchain network. type PaymentNetwork struct { ID int `json:"id"` NetworkCode string `json:"networkCode"` NetworkName string `json:"networkName"` ChainID *int `json:"chainId"` NativeCurrency string `json:"nativeCurrency"` NativeDecimals int `json:"nativeDecimals"` ExplorerURL *string `json:"explorerUrl"` ExplorerTxPath *string `json:"explorerTxPath"` IsTestnet bool `json:"isTestnet"` IsEnabled bool `json:"isEnabled"` MinConfirmations int `json:"minConfirmations"` AvgBlockTime *int `json:"avgBlockTime"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // PaymentToken represents a whitelisted ERC-20 token. type PaymentToken struct { ID int `json:"id"` NetworkID int `json:"networkId"` ContractAddress string `json:"contractAddress"` TokenSymbol string `json:"tokenSymbol"` TokenName string `json:"tokenName"` Decimals int `json:"decimals"` LogoURL *string `json:"logoUrl"` LogoIPFSCID *string `json:"logoIpfsCid"` IsVerified bool `json:"isVerified"` VerifiedBy *int `json:"verifiedBy"` VerifiedAt *time.Time `json:"verifiedAt"` VerificationSource *string `json:"verificationSource"` CoingeckoID *string `json:"coingeckoId"` PriceUSD *float64 `json:"priceUsd"` PriceUpdatedAt *time.Time `json:"priceUpdatedAt"` IsEnabled bool `json:"isEnabled"` IsStablecoin bool `json:"isStablecoin"` MinAmount *string `json:"minAmount"` MaxAmount *string `json:"maxAmount"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // PaymentWallet represents a company receiving wallet. type PaymentWallet struct { ID int `json:"id"` WalletName string `json:"walletName"` NetworkID int `json:"networkId"` Address string `json:"address"` AddressType string `json:"addressType"` IsActive bool `json:"isActive"` IsPrimary bool `json:"isPrimary"` RequiresApprovalAbove *float64 `json:"requiresApprovalAbove"` DailyLimit *float64 `json:"dailyLimit"` MonthlyLimit *float64 `json:"monthlyLimit"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // CryptoPaymentRequest for initiating a crypto payment. type CryptoPaymentRequest struct { InvoiceID int `json:"invoiceId"` NetworkID int `json:"networkId"` TokenID *int `json:"tokenId"` // NULL for native currency AmountUSD float64 `json:"amountUsd"` // Amount in USD to pay } // CryptoPaymentResponse contains payment address and details. type CryptoPaymentResponse struct { PaymentID int `json:"paymentId"` ToAddress string `json:"toAddress"` NetworkCode string `json:"networkCode"` TokenSymbol *string `json:"tokenSymbol"` AmountCrypto string `json:"amountCrypto"` AmountUSD float64 `json:"amountUsd"` ExchangeRate float64 `json:"exchangeRate"` ExplorerURL string `json:"explorerUrl"` ExpiresAt string `json:"expiresAt"` MinConfirmations int `json:"minConfirmations"` } // CryptoPaymentConfirmRequest for confirming a crypto payment. type CryptoPaymentConfirmRequest struct { PaymentID int `json:"paymentId"` TxHash string `json:"txHash"` FromAddress string `json:"fromAddress"` } // ===== DONATION TYPES ===== // DonationCampaign represents a fundraising campaign. type DonationCampaign struct { ID int `json:"id"` CampaignID string `json:"campaignId"` Name string `json:"name"` Slug string `json:"slug"` Description *string `json:"description"` ShortDescription *string `json:"shortDescription"` CoverImageURL *string `json:"coverImageUrl"` GoalAmount *float64 `json:"goalAmount"` GoalCurrency string `json:"goalCurrency"` RaisedAmount float64 `json:"raisedAmount"` DonorCount int `json:"donorCount"` StartDate *time.Time `json:"startDate"` EndDate *time.Time `json:"endDate"` IsPublic bool `json:"isPublic"` IsActive bool `json:"isActive"` AllowAnonymous bool `json:"allowAnonymous"` MinDonation float64 `json:"minDonation"` MaxDonation *float64 `json:"maxDonation"` Category *string `json:"category"` CreatedBy *int `json:"createdBy"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // Donation represents an individual donation. type Donation struct { ID int `json:"id"` DonationID string `json:"donationId"` CampaignID *int `json:"campaignId"` DonorID *int `json:"donorId"` IsAnonymous bool `json:"isAnonymous"` DonorName *string `json:"donorName"` DonorEmail *string `json:"donorEmail"` DonorMessage *string `json:"donorMessage"` Amount float64 `json:"amount"` Currency string `json:"currency"` AmountUSD *float64 `json:"amountUsd"` PaymentID *int `json:"paymentId"` PaymentMethod *string `json:"paymentMethod"` PaymentStatus string `json:"paymentStatus"` NetworkID *int `json:"networkId"` TokenID *int `json:"tokenId"` TxHash *string `json:"txHash"` FeeAmount *float64 `json:"feeAmount"` NetAmount *float64 `json:"netAmount"` IsRecurring bool `json:"isRecurring"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // CreateDonationRequest for making a donation. type CreateDonationRequest struct { CampaignID *int `json:"campaignId"` Amount float64 `json:"amount"` Currency string `json:"currency"` PaymentMethod string `json:"paymentMethod"` // 'stripe', 'crypto', 'paypal' NetworkID *int `json:"networkId"` // For crypto TokenID *int `json:"tokenId"` // For crypto tokens IsAnonymous bool `json:"isAnonymous"` DonorName *string `json:"donorName"` DonorMessage *string `json:"donorMessage"` } // DonationResponse after creating a donation. type DonationResponse struct { DonationID string `json:"donationId"` Status string `json:"status"` ClientSecret *string `json:"clientSecret,omitempty"` // For Stripe ToAddress *string `json:"toAddress,omitempty"` // For crypto AmountCrypto *string `json:"amountCrypto,omitempty"` // For crypto ExchangeRate *float64 `json:"exchangeRate,omitempty"` // For crypto } // TokenWhitelistRequest for admin token management. type TokenWhitelistRequest struct { NetworkID int `json:"networkId"` ContractAddress string `json:"contractAddress"` TokenSymbol string `json:"tokenSymbol"` TokenName string `json:"tokenName"` Decimals int `json:"decimals"` IsStablecoin bool `json:"isStablecoin"` CoingeckoID *string `json:"coingeckoId"` LogoURL *string `json:"logoUrl"` VerificationSource *string `json:"verificationSource"` } // ===== PAYPAL TYPES ===== // PayPalOrderRequest for creating a PayPal order. type PayPalOrderRequest struct { InvoiceID int `json:"invoiceId"` } // PayPalOrderResponse contains the PayPal order details. type PayPalOrderResponse struct { OrderID string `json:"orderId"` PaymentID int `json:"paymentId"` ApproveURL string `json:"approveUrl"` } // PayPalCaptureRequest for capturing a PayPal payment. type PayPalCaptureRequest struct { OrderID string `json:"orderId"` PaymentID int `json:"paymentId"` } // PayPalAccessToken for API authentication. type PayPalAccessToken struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } // PayPalOrder represents a PayPal order response. type PayPalOrder struct { ID string `json:"id"` Status string `json:"status"` Links []struct { Href string `json:"href"` Rel string `json:"rel"` Method string `json:"method"` } `json:"links"` } var db *sql.DB func main() { // Initialize Stripe stripeKey := os.Getenv("STRIPE_SECRET_KEY") if stripeKey != "" { stripe.Key = stripeKey log.Println("Stripe integration enabled") } else { log.Println("Warning: STRIPE_SECRET_KEY not set, Stripe functionality disabled") } // Initialize PayPal initPayPal() ensureJWTSecret() db = initDB() defer db.Close() // Invoice routes (protected - staff/admin can create, clients can view their own) http.HandleFunc("/invoices", authMiddleware(handleInvoices)) http.HandleFunc("/invoices/", authMiddleware(handleInvoiceByID)) // Payment routes (protected - authenticated users only) http.HandleFunc("/payments", authMiddleware(handlePayments)) http.HandleFunc("/payments/", authMiddleware(handlePaymentByID)) // Payment integration routes (protected) http.HandleFunc("/invoices/create-payment-intent", authMiddleware(createStripePaymentIntent)) // Crypto payment routes (protected) http.HandleFunc("/crypto/networks", handleNetworks) // GET - public list of networks http.HandleFunc("/crypto/tokens", handleTokens) // GET - public list of tokens http.HandleFunc("/crypto/initiate", authMiddleware(initiateCryptoPayment)) // POST - create crypto payment http.HandleFunc("/crypto/confirm", authMiddleware(confirmCryptoPayment)) // POST - confirm with tx hash http.HandleFunc("/crypto/status/", authMiddleware(getCryptoPaymentStatus)) // GET - check status // PayPal payment routes (protected) http.HandleFunc("/paypal/create-order", authMiddleware(createPayPalOrder)) // POST - create PayPal order http.HandleFunc("/paypal/capture", authMiddleware(capturePayPalPayment)) // POST - capture after approval http.HandleFunc("/webhooks/paypal", handlePayPalWebhook) // POST - PayPal webhooks // Admin token whitelist routes (admin only) http.HandleFunc("/admin/tokens", requireRole(handleAdminTokens, "ADMIN")) // GET, POST http.HandleFunc("/admin/tokens/", requireRole(handleAdminTokenByID, "ADMIN")) // PUT, DELETE http.HandleFunc("/admin/wallets", requireRole(handleAdminWallets, "ADMIN")) // GET, POST http.HandleFunc("/admin/wallets/", requireRole(handleAdminWalletByID, "ADMIN")) // PUT, DELETE // Donation routes http.HandleFunc("/donations/campaigns", handleDonationCampaigns) // GET (public), POST (admin) http.HandleFunc("/donations/campaigns/", handleDonationCampaignBySlug) // GET by slug (public) http.HandleFunc("/donations/donate", handleDonate) // POST - public donation http.HandleFunc("/donations/", authMiddleware(handleDonationByID)) // GET by ID (protected) // Webhook routes (public - but should verify signature) http.HandleFunc("/webhooks/stripe", handleStripeWebhook) // Health check (public) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "ok") }) server := &http.Server{ Addr: ":8080", Handler: corsMiddleware(http.DefaultServeMux), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } log.Println("Payment Service listening on :8080") log.Fatal(server.ListenAndServe()) } func initDB() *sql.DB { user := strings.TrimSpace(os.Getenv("DB_USER")) password := strings.TrimSpace(os.Getenv("DB_PASSWORD")) name := strings.TrimSpace(os.Getenv("DB_NAME")) host := strings.TrimSpace(os.Getenv("DB_HOST")) sslMode := strings.TrimSpace(os.Getenv("DB_SSL_MODE")) schema := strings.TrimSpace(os.Getenv("DB_SCHEMA")) if user == "" || password == "" || name == "" || host == "" { log.Fatal("Database configuration missing: DB_USER, DB_PASSWORD, DB_NAME, DB_HOST required") } // Secure default: require TLS for production if sslMode == "" { sslMode = "require" log.Println("WARNING: DB_SSL_MODE not set, defaulting to 'require' for security") } // Validate sslMode value validSSLModes := map[string]bool{ "disable": true, "require": true, "verify-ca": true, "verify-full": true, } if !validSSLModes[sslMode] { log.Fatalf("Invalid DB_SSL_MODE '%s'. Must be: disable, require, verify-ca, or verify-full", sslMode) } // Warn if using insecure mode if sslMode == "disable" { log.Println("WARNING: Database SSL is DISABLED. This should only be used for local development!") } // Validate schema value if provided validSchemas := map[string]bool{ "": true, "public": true, "dev": true, "testing": true, "prod": true, } if !validSchemas[schema] { log.Fatalf("Invalid DB_SCHEMA '%s'. Must be: dev, testing, prod, or empty for public", schema) } // Build connection string with optional search_path connStr := fmt.Sprintf("user=%s password=%s dbname=%s host=%s sslmode=%s", user, password, name, host, sslMode) // Add search_path if schema is specified if schema != "" && schema != "public" { connStr += fmt.Sprintf(" search_path=%s,public", schema) } database, err := sql.Open("postgres", connStr) if err != nil { log.Fatalf("Error opening database: %v", err) } if err := database.Ping(); err != nil { log.Fatalf("Error connecting to database: %v", err) } schemaInfo := "public" if schema != "" && schema != "public" { schemaInfo = schema } log.Printf("Successfully connected to database (SSL mode: %s, schema: %s)", sslMode, schemaInfo) return database } func corsMiddleware(next http.Handler) http.Handler { var corsConfigLogged bool return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Limit request body size to prevent DoS if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) } allowedOrigin := strings.TrimSpace(os.Getenv("CORS_ALLOW_ORIGIN")) if allowedOrigin == "" { allowedOrigin = "http://localhost:8090" if !corsConfigLogged { log.Println("WARNING: CORS_ALLOW_ORIGIN not set, defaulting to http://localhost:8090") corsConfigLogged = true } } else if allowedOrigin == "*" && !corsConfigLogged { log.Println("WARNING: CORS configured to allow ALL origins (*). This is insecure for production!") corsConfigLogged = true } // CORS headers w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // Security headers w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'") w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } func ensureJWTSecret() { secret := strings.TrimSpace(os.Getenv("JWT_SECRET")) if len(secret) < 32 { log.Fatal("JWT_SECRET must be set and at least 32 characters") } } // authMiddleware validates JWT token and extracts user info func authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "Authorization header required", http.StatusUnauthorized) return } tokenString := strings.TrimPrefix(authHeader, "Bearer ") if tokenString == authHeader { http.Error(w, "Invalid authorization format. Expected: Bearer ", http.StatusUnauthorized) return } // Parse and validate JWT token token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // Verify signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } // Get JWT secret from environment secret := os.Getenv("JWT_SECRET") if secret == "" { return nil, fmt.Errorf("JWT_SECRET not configured") } return []byte(secret), nil }) if err != nil { log.Printf("JWT parse error: %v", err) http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } if !token.Valid { http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } // Extract claims and add to request context claims, ok := token.Claims.(jwt.MapClaims) if !ok { http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } userID, err := extractUserID(claims) if err != nil { log.Printf("User ID extraction error: %v", err) http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } roles, err := extractRoles(claims) if err != nil { http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } ctx := r.Context() ctx = context.WithValue(ctx, "user_id", userID) ctx = context.WithValue(ctx, "roles", roles) r = r.WithContext(ctx) next.ServeHTTP(w, r) } } // requireRole middleware checks if user has required role func requireRole(next http.HandlerFunc, allowedRoles ...string) http.HandlerFunc { return authMiddleware(func(w http.ResponseWriter, r *http.Request) { userRoles, ok := r.Context().Value("roles").([]string) if !ok { http.Error(w, "No roles found in token", http.StatusForbidden) return } // Check if user has any of the allowed roles hasRole := false for _, userRole := range userRoles { for _, allowedRole := range allowedRoles { if userRole == allowedRole { hasRole = true break } } if hasRole { break } } if !hasRole { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } func extractRoles(claims jwt.MapClaims) ([]string, error) { rawRoles, ok := claims["roles"] if !ok { return nil, errors.New("roles missing") } switch v := rawRoles.(type) { case []interface{}: out := make([]string, 0, len(v)) for _, r := range v { roleStr, ok := r.(string) if !ok { return nil, errors.New("role value not string") } out = append(out, roleStr) } return out, nil case []string: return v, nil default: return nil, errors.New("roles claim type invalid") } } func extractUserID(claims jwt.MapClaims) (int, error) { if id, ok := claims["user_id"]; ok { if idNum, err := numericToInt(id); err == nil { return idNum, nil } } if id, ok := claims["userId"]; ok { if idNum, err := numericToInt(id); err == nil { return idNum, nil } } return 0, errors.New("user_id claim missing or invalid") } func numericToInt(v interface{}) (int, error) { switch val := v.(type) { case float64: return int(val), nil case int: return val, nil case json.Number: i, err := val.Int64() return int(i), err default: return 0, errors.New("not a number") } } func hasAnyRole(ctx context.Context, allowedRoles ...string) bool { raw, ok := ctx.Value("roles").([]string) if !ok { return false } for _, role := range raw { // SUPERUSER has god-like permissions - always returns true if role == "SUPERUSER" { return true } for _, allowed := range allowedRoles { if role == allowed { return true } } } return false } // ===== INVOICE HANDLERS ===== func handleInvoices(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listInvoices(w, r) case http.MethodPost: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } createInvoice(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handleInvoiceByID(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 || parts[2] == "create-payment-intent" { return // Let other handlers handle this } id, err := strconv.Atoi(parts[2]) if err != nil { http.Error(w, "Invalid invoice ID", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: getInvoice(w, r, id) case http.MethodPut: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } updateInvoice(w, r, id) case http.MethodDelete: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } deleteInvoice(w, r, id) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listInvoices(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) clientIDParam := r.URL.Query().Get("client_id") var rows *sql.Rows var err error // Authorization: STAFF/ADMIN can see all or filtered, CLIENTs only see their own if hasAnyRole(r.Context(), "STAFF", "ADMIN") { if clientIDParam != "" { rows, err = db.Query(` SELECT id, invoice_number, project_id, client_id, amount, currency, status, due_date, issued_date, paid_date, blockchain_tx_hash, ipfs_document_cid, notes, created_at, updated_at FROM invoices WHERE client_id = $1 ORDER BY created_at DESC `, clientIDParam) } else { rows, err = db.Query(` SELECT id, invoice_number, project_id, client_id, amount, currency, status, due_date, issued_date, paid_date, blockchain_tx_hash, ipfs_document_cid, notes, created_at, updated_at FROM invoices ORDER BY created_at DESC `) } } else { // CLIENT users only see their own invoices rows, err = db.Query(` SELECT id, invoice_number, project_id, client_id, amount, currency, status, due_date, issued_date, paid_date, blockchain_tx_hash, ipfs_document_cid, notes, created_at, updated_at FROM invoices WHERE client_id = $1 ORDER BY created_at DESC `, userID) } if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } defer rows.Close() var invoices []Invoice for rows.Next() { var inv Invoice err := rows.Scan(&inv.ID, &inv.InvoiceNumber, &inv.ProjectID, &inv.ClientID, &inv.Amount, &inv.Currency, &inv.Status, &inv.DueDate, &inv.IssuedDate, &inv.PaidDate, &inv.BlockchainTxHash, &inv.IPFSDocumentCID, &inv.Notes, &inv.CreatedAt, &inv.UpdatedAt) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } invoices = append(invoices, inv) } // Check for errors during iteration if err = rows.Err(); err != nil { log.Printf("Rows iteration error: %v", err) http.Error(w, "Failed to fetch invoices", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(invoices) } func createInvoice(w http.ResponseWriter, r *http.Request) { var inv Invoice if err := json.NewDecoder(r.Body).Decode(&inv); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Validate required fields if inv.InvoiceNumber == "" || inv.ClientID == 0 || inv.Amount <= 0 { http.Error(w, "Invoice number, client_id, and amount are required", http.StatusBadRequest) return } // Set default status and currency if inv.Status == "" { inv.Status = "DRAFT" } if inv.Currency == "" { inv.Currency = "USD" } err := db.QueryRow(` INSERT INTO invoices (invoice_number, project_id, client_id, amount, currency, status, due_date, issued_date, ipfs_document_cid, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at `, inv.InvoiceNumber, inv.ProjectID, inv.ClientID, inv.Amount, inv.Currency, inv.Status, inv.DueDate, inv.IssuedDate, inv.IPFSDocumentCID, inv.Notes). Scan(&inv.ID, &inv.CreatedAt, &inv.UpdatedAt) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Invoice number already exists", http.StatusConflict) return } safeError(w, "database error", err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(inv) } func getInvoice(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) var inv Invoice err := db.QueryRow(` SELECT id, invoice_number, project_id, client_id, amount, currency, status, due_date, issued_date, paid_date, blockchain_tx_hash, ipfs_document_cid, notes, created_at, updated_at FROM invoices WHERE id = $1 `, id).Scan(&inv.ID, &inv.InvoiceNumber, &inv.ProjectID, &inv.ClientID, &inv.Amount, &inv.Currency, &inv.Status, &inv.DueDate, &inv.IssuedDate, &inv.PaidDate, &inv.BlockchainTxHash, &inv.IPFSDocumentCID, &inv.Notes, &inv.CreatedAt, &inv.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Invoice not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization: Check if user is the client or has elevated role isOwner := (inv.ClientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden: you do not have access to this invoice", http.StatusForbidden) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(inv) } func updateInvoice(w http.ResponseWriter, r *http.Request, id int) { // First verify invoice exists var existingClientID int var existingStatus string err := db.QueryRow(`SELECT client_id, status FROM invoices WHERE id = $1`, id).Scan(&existingClientID, &existingStatus) if err == sql.ErrNoRows { http.Error(w, "Invoice not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Prevent modification of PAID invoices (business rule) if existingStatus == "PAID" { http.Error(w, "Cannot modify a paid invoice", http.StatusForbidden) return } var inv Invoice if err := json.NewDecoder(r.Body).Decode(&inv); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } result, err := db.Exec(` UPDATE invoices SET invoice_number = $1, project_id = $2, client_id = $3, amount = $4, currency = $5, status = $6, due_date = $7, issued_date = $8, paid_date = $9, blockchain_tx_hash = $10, ipfs_document_cid = $11, notes = $12 WHERE id = $13 `, inv.InvoiceNumber, inv.ProjectID, inv.ClientID, inv.Amount, inv.Currency, inv.Status, inv.DueDate, inv.IssuedDate, inv.PaidDate, inv.BlockchainTxHash, inv.IPFSDocumentCID, inv.Notes, id) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Invoice not found", http.StatusNotFound) return } inv.ID = id w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(inv) } func deleteInvoice(w http.ResponseWriter, r *http.Request, id int) { // First verify invoice exists and check status var existingStatus string err := db.QueryRow(`SELECT status FROM invoices WHERE id = $1`, id).Scan(&existingStatus) if err == sql.ErrNoRows { http.Error(w, "Invoice not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Prevent deletion of PAID invoices (business rule - audit trail) if existingStatus == "PAID" { http.Error(w, "Cannot delete a paid invoice - it must be kept for audit purposes", http.StatusForbidden) return } result, err := db.Exec("DELETE FROM invoices WHERE id = $1", id) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Invoice not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // ===== PAYMENT HANDLERS ===== func handlePayments(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listPayments(w, r) case http.MethodPost: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } createPayment(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handlePaymentByID(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 { http.Error(w, "Invalid path", http.StatusBadRequest) return } id, err := strconv.Atoi(parts[2]) if err != nil { http.Error(w, "Invalid payment ID", http.StatusBadRequest) return } if r.Method == http.MethodGet { getPayment(w, r, id) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listPayments(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) invoiceID := r.URL.Query().Get("invoice_id") var rows *sql.Rows var err error // Authorization: STAFF/ADMIN can see all payments, CLIENTs only see payments for their invoices if hasAnyRole(r.Context(), "STAFF", "ADMIN") { if invoiceID != "" { rows, err = db.Query(` SELECT id, invoice_id, amount, currency, payment_method, status, transaction_id, blockchain_tx_hash, blockchain_network, payment_processor, processor_fee, processed_at, created_at, updated_at FROM payments WHERE invoice_id = $1 ORDER BY created_at DESC `, invoiceID) } else { rows, err = db.Query(` SELECT id, invoice_id, amount, currency, payment_method, status, transaction_id, blockchain_tx_hash, blockchain_network, payment_processor, processor_fee, processed_at, created_at, updated_at FROM payments ORDER BY created_at DESC `) } } else { // CLIENT users only see payments for their own invoices if invoiceID != "" { // Verify client owns this invoice var ownerID int err = db.QueryRow(`SELECT client_id FROM invoices WHERE id = $1`, invoiceID).Scan(&ownerID) if err == sql.ErrNoRows { http.Error(w, "Invoice not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Forbidden: you do not have access to this invoice's payments", http.StatusForbidden) return } rows, err = db.Query(` SELECT id, invoice_id, amount, currency, payment_method, status, transaction_id, blockchain_tx_hash, blockchain_network, payment_processor, processor_fee, processed_at, created_at, updated_at FROM payments WHERE invoice_id = $1 ORDER BY created_at DESC `, invoiceID) } else { // Get all payments for invoices owned by this client rows, err = db.Query(` SELECT p.id, p.invoice_id, p.amount, p.currency, p.payment_method, p.status, p.transaction_id, p.blockchain_tx_hash, p.blockchain_network, p.payment_processor, p.processor_fee, p.processed_at, p.created_at, p.updated_at FROM payments p INNER JOIN invoices i ON p.invoice_id = i.id WHERE i.client_id = $1 ORDER BY p.created_at DESC `, userID) } } if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } defer rows.Close() var payments []Payment for rows.Next() { var p Payment err := rows.Scan(&p.ID, &p.InvoiceID, &p.Amount, &p.Currency, &p.PaymentMethod, &p.Status, &p.TransactionID, &p.BlockchainTxHash, &p.BlockchainNetwork, &p.PaymentProcessor, &p.ProcessorFee, &p.ProcessedAt, &p.CreatedAt, &p.UpdatedAt) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } payments = append(payments, p) } // Check for errors during iteration if err = rows.Err(); err != nil { log.Printf("Rows iteration error: %v", err) http.Error(w, "Failed to fetch payments", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(payments) } func createPayment(w http.ResponseWriter, r *http.Request) { var p Payment if err := json.NewDecoder(r.Body).Decode(&p); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Validate required fields if p.InvoiceID == 0 || p.Amount <= 0 { http.Error(w, "Invoice ID and amount are required", http.StatusBadRequest) return } // Set defaults if p.Status == "" { p.Status = "PENDING" } if p.Currency == "" { p.Currency = "USD" } err := db.QueryRow(` INSERT INTO payments (invoice_id, amount, currency, payment_method, status, transaction_id, blockchain_tx_hash, blockchain_network, payment_processor, processor_fee, processed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at `, p.InvoiceID, p.Amount, p.Currency, p.PaymentMethod, p.Status, p.TransactionID, p.BlockchainTxHash, p.BlockchainNetwork, p.PaymentProcessor, p.ProcessorFee, p.ProcessedAt). Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(p) } func getPayment(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) var p Payment err := db.QueryRow(` SELECT id, invoice_id, amount, currency, payment_method, status, transaction_id, blockchain_tx_hash, blockchain_network, payment_processor, processor_fee, processed_at, created_at, updated_at FROM payments WHERE id = $1 `, id).Scan(&p.ID, &p.InvoiceID, &p.Amount, &p.Currency, &p.PaymentMethod, &p.Status, &p.TransactionID, &p.BlockchainTxHash, &p.BlockchainNetwork, &p.PaymentProcessor, &p.ProcessorFee, &p.ProcessedAt, &p.CreatedAt, &p.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Payment not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization: Check if user owns the invoice or has elevated role if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { var ownerID int err = db.QueryRow(`SELECT client_id FROM invoices WHERE id = $1`, p.InvoiceID).Scan(&ownerID) if err != nil { http.Error(w, "Failed to verify ownership", http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Forbidden: you do not have access to this payment", http.StatusForbidden) return } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(p) } // ===== STRIPE INTEGRATION ===== func createStripePaymentIntent(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } userID := r.Context().Value("user_id").(int) if stripe.Key == "" { http.Error(w, "Stripe not configured", http.StatusServiceUnavailable) return } var req CreatePaymentIntentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Get invoice details including client_id for ownership check var inv Invoice err := db.QueryRow(` SELECT id, client_id, amount, currency FROM invoices WHERE id = $1 `, req.InvoiceID).Scan(&inv.ID, &inv.ClientID, &inv.Amount, &inv.Currency) if err == sql.ErrNoRows { http.Error(w, "Invoice not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization: Check if user owns the invoice or has elevated role isOwner := (inv.ClientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden: you can only pay your own invoices", http.StatusForbidden) return } // Convert amount to cents for Stripe amountCents := int64(inv.Amount * 100) // Create Stripe Payment Intent params := &stripe.PaymentIntentParams{ Amount: stripe.Int64(amountCents), Currency: stripe.String(strings.ToLower(inv.Currency)), } pi, err := paymentintent.New(params) if err != nil { http.Error(w, fmt.Sprintf("Stripe error: %v", err), http.StatusInternalServerError) return } // Create payment record in database var payment Payment processor := "stripe" transactionID := pi.ID err = db.QueryRow(` INSERT INTO payments (invoice_id, amount, currency, payment_method, status, transaction_id, payment_processor) VALUES ($1, $2, $3, 'CREDIT_CARD', 'PENDING', $4, $5) RETURNING id `, req.InvoiceID, inv.Amount, inv.Currency, transactionID, processor). Scan(&payment.ID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Return client secret resp := CreatePaymentIntentResponse{ ClientSecret: pi.ClientSecret, PaymentID: payment.ID, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func handleStripeWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } secret := strings.TrimSpace(os.Getenv("STRIPE_WEBHOOK_SECRET")) if secret == "" { log.Println("ERROR: STRIPE_WEBHOOK_SECRET not configured") http.Error(w, "Service configuration error", http.StatusServiceUnavailable) return } payload, err := io.ReadAll(r.Body) if err != nil { log.Printf("Webhook error: failed to read body: %v", err) http.Error(w, "Failed to read request body", http.StatusBadRequest) return } // Verify webhook signature (CRITICAL SECURITY CHECK) event, err := webhook.ConstructEvent(payload, r.Header.Get("Stripe-Signature"), secret) if err != nil { log.Printf("Webhook signature verification failed: %v", err) http.Error(w, "Invalid webhook signature", http.StatusBadRequest) return } // Process webhook events switch event.Type { case "payment_intent.succeeded": var paymentIntent stripe.PaymentIntent err := json.Unmarshal(event.Data.Raw, &paymentIntent) if err != nil { log.Printf("Error parsing webhook JSON: %v", err) http.Error(w, "Invalid event data", http.StatusBadRequest) return } // Update payment status in database result, err := db.Exec(` UPDATE payments SET status = 'COMPLETED', processed_at = NOW() WHERE transaction_id = $1 AND payment_processor = 'stripe' `, paymentIntent.ID) if err != nil { log.Printf("Failed to update payment status: %v", err) http.Error(w, "Database error", http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected > 0 { // Get invoice ID from payment var invoiceID int err = db.QueryRow(` SELECT invoice_id FROM payments WHERE transaction_id = $1 AND payment_processor = 'stripe' `, paymentIntent.ID).Scan(&invoiceID) if err == nil { // Update invoice status to PAID _, err = db.Exec(` UPDATE invoices SET status = 'PAID', paid_date = CURRENT_DATE WHERE id = $1 `, invoiceID) if err != nil { log.Printf("Failed to update invoice status: %v", err) } else { log.Printf("Payment intent succeeded: %s, invoice %d marked as PAID", paymentIntent.ID, invoiceID) } } } else { log.Printf("Warning: Payment intent %s not found in database", paymentIntent.ID) } case "payment_intent.payment_failed": var paymentIntent stripe.PaymentIntent err := json.Unmarshal(event.Data.Raw, &paymentIntent) if err != nil { log.Printf("Error parsing webhook JSON: %v", err) http.Error(w, "Invalid event data", http.StatusBadRequest) return } // Update payment status to FAILED _, err = db.Exec(` UPDATE payments SET status = 'FAILED' WHERE transaction_id = $1 AND payment_processor = 'stripe' `, paymentIntent.ID) if err != nil { log.Printf("Failed to update failed payment: %v", err) } else { log.Printf("Payment intent failed: %s", paymentIntent.ID) } case "charge.refunded": var charge stripe.Charge err := json.Unmarshal(event.Data.Raw, &charge) if err != nil { log.Printf("Error parsing webhook JSON: %v", err) http.Error(w, "Invalid event data", http.StatusBadRequest) return } // Update payment and invoice status for refund _, err = db.Exec(` UPDATE payments SET status = 'REFUNDED' WHERE transaction_id = $1 AND payment_processor = 'stripe' `, charge.PaymentIntent.ID) if err != nil { log.Printf("Failed to update refunded payment: %v", err) } else { log.Printf("Charge refunded: %s", charge.ID) } default: log.Printf("Unhandled webhook event type: %s", event.Type) } w.WriteHeader(http.StatusOK) } // ===== CRYPTO PAYMENT HANDLERS ===== // handleNetworks returns list of enabled blockchain networks. func handleNetworks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } rows, err := db.Query(` SELECT id, network_code, network_name, chain_id, native_currency, native_decimals, explorer_url, explorer_tx_path, is_testnet, is_enabled, min_confirmations, avg_block_time, created_at, updated_at FROM payment_networks WHERE is_enabled = TRUE ORDER BY network_name `) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } defer rows.Close() var networks []PaymentNetwork for rows.Next() { var n PaymentNetwork err := rows.Scan(&n.ID, &n.NetworkCode, &n.NetworkName, &n.ChainID, &n.NativeCurrency, &n.NativeDecimals, &n.ExplorerURL, &n.ExplorerTxPath, &n.IsTestnet, &n.IsEnabled, &n.MinConfirmations, &n.AvgBlockTime, &n.CreatedAt, &n.UpdatedAt) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } networks = append(networks, n) } if err = rows.Err(); err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(networks) } // handleTokens returns list of whitelisted tokens. func handleTokens(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } networkID := r.URL.Query().Get("network_id") var rows *sql.Rows var err error if networkID != "" { rows, err = db.Query(` SELECT t.id, t.network_id, t.contract_address, t.token_symbol, t.token_name, t.decimals, t.logo_url, t.logo_ipfs_cid, t.is_verified, t.verified_by, t.verified_at, t.verification_source, t.coingecko_id, t.price_usd, t.price_updated_at, t.is_enabled, t.is_stablecoin, t.min_amount, t.max_amount, t.created_at, t.updated_at FROM payment_tokens t INNER JOIN payment_networks n ON t.network_id = n.id WHERE t.is_enabled = TRUE AND t.is_verified = TRUE AND n.is_enabled = TRUE AND t.network_id = $1 ORDER BY t.token_symbol `, networkID) } else { rows, err = db.Query(` SELECT t.id, t.network_id, t.contract_address, t.token_symbol, t.token_name, t.decimals, t.logo_url, t.logo_ipfs_cid, t.is_verified, t.verified_by, t.verified_at, t.verification_source, t.coingecko_id, t.price_usd, t.price_updated_at, t.is_enabled, t.is_stablecoin, t.min_amount, t.max_amount, t.created_at, t.updated_at FROM payment_tokens t INNER JOIN payment_networks n ON t.network_id = n.id WHERE t.is_enabled = TRUE AND t.is_verified = TRUE AND n.is_enabled = TRUE ORDER BY n.network_name, t.token_symbol `) } if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } defer rows.Close() var tokens []PaymentToken for rows.Next() { var t PaymentToken err := rows.Scan(&t.ID, &t.NetworkID, &t.ContractAddress, &t.TokenSymbol, &t.TokenName, &t.Decimals, &t.LogoURL, &t.LogoIPFSCID, &t.IsVerified, &t.VerifiedBy, &t.VerifiedAt, &t.VerificationSource, &t.CoingeckoID, &t.PriceUSD, &t.PriceUpdatedAt, &t.IsEnabled, &t.IsStablecoin, &t.MinAmount, &t.MaxAmount, &t.CreatedAt, &t.UpdatedAt) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } tokens = append(tokens, t) } if err = rows.Err(); err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tokens) } // initiateCryptoPayment creates a crypto payment request. func initiateCryptoPayment(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } userID := r.Context().Value("user_id").(int) var req CryptoPaymentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Validate invoice exists and belongs to user or user has permission var inv Invoice err := db.QueryRow(` SELECT id, client_id, amount, currency, status FROM invoices WHERE id = $1 `, req.InvoiceID).Scan(&inv.ID, &inv.ClientID, &inv.Amount, &inv.Currency, &inv.Status) if err == sql.ErrNoRows { http.Error(w, "Invoice not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization check isOwner := (inv.ClientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden: you can only pay your own invoices", http.StatusForbidden) return } if inv.Status == "PAID" { http.Error(w, "Invoice is already paid", http.StatusBadRequest) return } // Get network details var network PaymentNetwork err = db.QueryRow(` SELECT id, network_code, network_name, native_currency, native_decimals, explorer_url, min_confirmations, is_enabled FROM payment_networks WHERE id = $1 `, req.NetworkID).Scan(&network.ID, &network.NetworkCode, &network.NetworkName, &network.NativeCurrency, &network.NativeDecimals, &network.ExplorerURL, &network.MinConfirmations, &network.IsEnabled) if err == sql.ErrNoRows { http.Error(w, "Network not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } if !network.IsEnabled { http.Error(w, "Network is not enabled", http.StatusBadRequest) return } // Get primary wallet for this network var wallet PaymentWallet err = db.QueryRow(` SELECT id, address FROM payment_wallets WHERE network_id = $1 AND is_active = TRUE AND is_primary = TRUE `, req.NetworkID).Scan(&wallet.ID, &wallet.Address) if err == sql.ErrNoRows { http.Error(w, "No payment wallet configured for this network", http.StatusServiceUnavailable) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Determine payment method and get exchange rate var paymentMethod string var tokenSymbol *string var exchangeRate float64 var amountCrypto *big.Float var decimals int if req.TokenID != nil { // Token payment var token PaymentToken err = db.QueryRow(` SELECT id, token_symbol, decimals, is_verified, is_enabled, price_usd FROM payment_tokens WHERE id = $1 AND network_id = $2 `, *req.TokenID, req.NetworkID).Scan(&token.ID, &token.TokenSymbol, &token.Decimals, &token.IsVerified, &token.IsEnabled, &token.PriceUSD) if err == sql.ErrNoRows { http.Error(w, "Token not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } if !token.IsVerified || !token.IsEnabled { http.Error(w, "Token is not available for payments", http.StatusBadRequest) return } tokenSymbol = &token.TokenSymbol decimals = token.Decimals paymentMethod = "CRYPTO_TOKEN" // Get exchange rate (use cached price or fetch from API) if token.PriceUSD != nil && *token.PriceUSD > 0 { exchangeRate = *token.PriceUSD } else { // Fallback: use stored exchange rate err = db.QueryRow(` SELECT rate FROM exchange_rates WHERE base_currency = $1 AND quote_currency = 'USD' ORDER BY fetched_at DESC LIMIT 1 `, token.TokenSymbol).Scan(&exchangeRate) if err != nil { http.Error(w, "Exchange rate not available for this token", http.StatusServiceUnavailable) return } } } else { // Native currency payment decimals = network.NativeDecimals switch network.NativeCurrency { case "ETH": paymentMethod = "CRYPTO_ETH" case "BTC": paymentMethod = "CRYPTO_BTC" case "MATIC": paymentMethod = "CRYPTO_MATIC" default: paymentMethod = "CRYPTO" } // Get exchange rate for native currency err = db.QueryRow(` SELECT rate FROM exchange_rates WHERE base_currency = $1 AND quote_currency = 'USD' ORDER BY fetched_at DESC LIMIT 1 `, network.NativeCurrency).Scan(&exchangeRate) if err != nil { // Use fallback rates for common currencies switch network.NativeCurrency { case "ETH": exchangeRate = 3000 // Fallback case "BTC": exchangeRate = 50000 // Fallback case "MATIC": exchangeRate = 1 // Fallback default: http.Error(w, "Exchange rate not available", http.StatusServiceUnavailable) return } log.Printf("Warning: Using fallback exchange rate for %s", network.NativeCurrency) } } // Calculate crypto amount amountUSD := req.AmountUSD if amountUSD <= 0 { amountUSD = inv.Amount } amountCrypto = big.NewFloat(amountUSD / exchangeRate) // Format amount with proper decimals amountCryptoStr := formatCryptoAmount(amountCrypto, decimals) // Create payment record var paymentID int err = db.QueryRow(` INSERT INTO payments (invoice_id, amount, currency, payment_method, status, payment_processor) VALUES ($1, $2, $3, $4, 'PENDING', 'direct_crypto') RETURNING id `, req.InvoiceID, amountUSD, "USD", paymentMethod).Scan(&paymentID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Create crypto details record _, err = db.Exec(` INSERT INTO payment_crypto_details (payment_id, network_id, token_id, to_address, amount_crypto, amount_usd_at_time, exchange_rate, required_confirmations) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, paymentID, req.NetworkID, req.TokenID, wallet.Address, amountCryptoStr, amountUSD, exchangeRate, network.MinConfirmations) if err != nil { // Rollback payment record db.Exec("DELETE FROM payments WHERE id = $1", paymentID) safeError(w, "database error", err, http.StatusInternalServerError) return } // Log audit logPaymentAudit(paymentID, &req.InvoiceID, "created", "", "PENDING", amountUSD, "USD", &userID, r) // Build response explorerURL := "" if network.ExplorerURL != nil { explorerURL = *network.ExplorerURL } resp := CryptoPaymentResponse{ PaymentID: paymentID, ToAddress: wallet.Address, NetworkCode: network.NetworkCode, TokenSymbol: tokenSymbol, AmountCrypto: amountCryptoStr, AmountUSD: amountUSD, ExchangeRate: exchangeRate, ExplorerURL: explorerURL, ExpiresAt: time.Now().Add(30 * time.Minute).Format(time.RFC3339), MinConfirmations: network.MinConfirmations, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // confirmCryptoPayment records a transaction hash for verification. func confirmCryptoPayment(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } userID := r.Context().Value("user_id").(int) var req CryptoPaymentConfirmRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } if req.TxHash == "" { http.Error(w, "Transaction hash is required", http.StatusBadRequest) return } // Validate tx hash format (basic check) if len(req.TxHash) < 10 || len(req.TxHash) > 100 { http.Error(w, "Invalid transaction hash format", http.StatusBadRequest) return } // Get payment and verify ownership var invoiceID, clientID int var status string err := db.QueryRow(` SELECT p.invoice_id, i.client_id, p.status FROM payments p INNER JOIN invoices i ON p.invoice_id = i.id WHERE p.id = $1 `, req.PaymentID).Scan(&invoiceID, &clientID, &status) if err == sql.ErrNoRows { http.Error(w, "Payment not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization check isOwner := (clientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } if status != "PENDING" { http.Error(w, "Payment is not pending", http.StatusBadRequest) return } // Update crypto details with tx hash result, err := db.Exec(` UPDATE payment_crypto_details SET tx_hash = $1, from_address = $2 WHERE payment_id = $3 AND tx_hash IS NULL `, req.TxHash, req.FromAddress, req.PaymentID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Payment already has a transaction hash", http.StatusConflict) return } // Update payment status to CONFIRMING _, err = db.Exec(` UPDATE payments SET status = 'CONFIRMING' WHERE id = $1 `, req.PaymentID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Log audit logPaymentAudit(req.PaymentID, &invoiceID, "tx_submitted", "PENDING", "CONFIRMING", 0, "", &userID, r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Transaction submitted for confirmation", "paymentId": req.PaymentID, "status": "CONFIRMING", }) } // getCryptoPaymentStatus returns the current status of a crypto payment. func getCryptoPaymentStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.Error(w, "Invalid path", http.StatusBadRequest) return } paymentID, err := strconv.Atoi(parts[3]) if err != nil { http.Error(w, "Invalid payment ID", http.StatusBadRequest) return } userID := r.Context().Value("user_id").(int) // Get payment details var p Payment var clientID int err = db.QueryRow(` SELECT p.id, p.invoice_id, p.amount, p.currency, p.payment_method, p.status, p.blockchain_tx_hash, p.blockchain_network, p.created_at, p.updated_at, i.client_id FROM payments p INNER JOIN invoices i ON p.invoice_id = i.id WHERE p.id = $1 `, paymentID).Scan(&p.ID, &p.InvoiceID, &p.Amount, &p.Currency, &p.PaymentMethod, &p.Status, &p.BlockchainTxHash, &p.BlockchainNetwork, &p.CreatedAt, &p.UpdatedAt, &clientID) if err == sql.ErrNoRows { http.Error(w, "Payment not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization check isOwner := (clientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } // Get crypto details var cryptoDetails struct { TxHash *string `json:"txHash"` FromAddress *string `json:"fromAddress"` ToAddress string `json:"toAddress"` AmountCrypto string `json:"amountCrypto"` Confirmations int `json:"confirmations"` RequiredConfirmations int `json:"requiredConfirmations"` IsConfirmed bool `json:"isConfirmed"` NetworkCode string `json:"networkCode"` ExplorerURL *string `json:"explorerUrl"` } err = db.QueryRow(` SELECT cd.tx_hash, cd.from_address, cd.to_address, cd.amount_crypto, cd.confirmations, cd.required_confirmations, cd.is_confirmed, n.network_code, n.explorer_url FROM payment_crypto_details cd INNER JOIN payment_networks n ON cd.network_id = n.id WHERE cd.payment_id = $1 `, paymentID).Scan(&cryptoDetails.TxHash, &cryptoDetails.FromAddress, &cryptoDetails.ToAddress, &cryptoDetails.AmountCrypto, &cryptoDetails.Confirmations, &cryptoDetails.RequiredConfirmations, &cryptoDetails.IsConfirmed, &cryptoDetails.NetworkCode, &cryptoDetails.ExplorerURL) if err != nil && err != sql.ErrNoRows { safeError(w, "database error", err, http.StatusInternalServerError) return } response := map[string]interface{}{ "payment": p, "crypto": cryptoDetails, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // ===== ADMIN TOKEN WHITELIST HANDLERS ===== func handleAdminTokens(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listAllTokens(w, r) case http.MethodPost: createToken(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listAllTokens(w http.ResponseWriter, r *http.Request) { rows, err := db.Query(` SELECT t.id, t.network_id, t.contract_address, t.token_symbol, t.token_name, t.decimals, t.logo_url, t.logo_ipfs_cid, t.is_verified, t.verified_by, t.verified_at, t.verification_source, t.coingecko_id, t.price_usd, t.price_updated_at, t.is_enabled, t.is_stablecoin, t.min_amount, t.max_amount, t.created_at, t.updated_at FROM payment_tokens t ORDER BY t.network_id, t.token_symbol `) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } defer rows.Close() var tokens []PaymentToken for rows.Next() { var t PaymentToken err := rows.Scan(&t.ID, &t.NetworkID, &t.ContractAddress, &t.TokenSymbol, &t.TokenName, &t.Decimals, &t.LogoURL, &t.LogoIPFSCID, &t.IsVerified, &t.VerifiedBy, &t.VerifiedAt, &t.VerificationSource, &t.CoingeckoID, &t.PriceUSD, &t.PriceUpdatedAt, &t.IsEnabled, &t.IsStablecoin, &t.MinAmount, &t.MaxAmount, &t.CreatedAt, &t.UpdatedAt) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } tokens = append(tokens, t) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tokens) } func createToken(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) var req TokenWhitelistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Validate required fields if req.ContractAddress == "" || req.TokenSymbol == "" || req.TokenName == "" { http.Error(w, "Contract address, symbol, and name are required", http.StatusBadRequest) return } // Validate network exists var networkExists bool err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM payment_networks WHERE id = $1)`, req.NetworkID).Scan(&networkExists) if err != nil || !networkExists { http.Error(w, "Invalid network ID", http.StatusBadRequest) return } // Validate contract address format (basic Ethereum check) if !strings.HasPrefix(req.ContractAddress, "0x") || len(req.ContractAddress) != 42 { http.Error(w, "Invalid contract address format", http.StatusBadRequest) return } var tokenID int err = db.QueryRow(` INSERT INTO payment_tokens (network_id, contract_address, token_symbol, token_name, decimals, is_stablecoin, coingecko_id, logo_url, verification_source, is_verified, verified_by, verified_at, is_enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, TRUE, $10, NOW(), TRUE) RETURNING id `, req.NetworkID, strings.ToLower(req.ContractAddress), strings.ToUpper(req.TokenSymbol), req.TokenName, req.Decimals, req.IsStablecoin, req.CoingeckoID, req.LogoURL, req.VerificationSource, userID).Scan(&tokenID) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Token already exists for this network", http.StatusConflict) return } safeError(w, "database error", err, http.StatusInternalServerError) return } log.Printf("Admin %d added token %s (%s) on network %d", userID, req.TokenSymbol, req.ContractAddress, req.NetworkID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ "id": tokenID, "message": "Token added to whitelist", }) } func handleAdminTokenByID(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.Error(w, "Invalid path", http.StatusBadRequest) return } tokenID, err := strconv.Atoi(parts[3]) if err != nil { http.Error(w, "Invalid token ID", http.StatusBadRequest) return } switch r.Method { case http.MethodPut: updateToken(w, r, tokenID) case http.MethodDelete: deleteToken(w, r, tokenID) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func updateToken(w http.ResponseWriter, r *http.Request, tokenID int) { var updates struct { IsEnabled *bool `json:"isEnabled"` IsVerified *bool `json:"isVerified"` IsStablecoin *bool `json:"isStablecoin"` PriceUSD *float64 `json:"priceUsd"` LogoURL *string `json:"logoUrl"` CoingeckoID *string `json:"coingeckoId"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Build dynamic update query var setClauses []string var args []interface{} argIndex := 1 if updates.IsEnabled != nil { setClauses = append(setClauses, fmt.Sprintf("is_enabled = $%d", argIndex)) args = append(args, *updates.IsEnabled) argIndex++ } if updates.IsVerified != nil { setClauses = append(setClauses, fmt.Sprintf("is_verified = $%d", argIndex)) args = append(args, *updates.IsVerified) argIndex++ } if updates.IsStablecoin != nil { setClauses = append(setClauses, fmt.Sprintf("is_stablecoin = $%d", argIndex)) args = append(args, *updates.IsStablecoin) argIndex++ } if updates.PriceUSD != nil { setClauses = append(setClauses, fmt.Sprintf("price_usd = $%d, price_updated_at = NOW()", argIndex)) args = append(args, *updates.PriceUSD) argIndex++ } if updates.LogoURL != nil { setClauses = append(setClauses, fmt.Sprintf("logo_url = $%d", argIndex)) args = append(args, *updates.LogoURL) argIndex++ } if updates.CoingeckoID != nil { setClauses = append(setClauses, fmt.Sprintf("coingecko_id = $%d", argIndex)) args = append(args, *updates.CoingeckoID) argIndex++ } if len(setClauses) == 0 { http.Error(w, "No fields to update", http.StatusBadRequest) return } args = append(args, tokenID) query := fmt.Sprintf("UPDATE payment_tokens SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argIndex) result, err := db.Exec(query, args...) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Token not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Token updated"}) } func deleteToken(w http.ResponseWriter, r *http.Request, tokenID int) { result, err := db.Exec("DELETE FROM payment_tokens WHERE id = $1", tokenID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Token not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // ===== ADMIN WALLET HANDLERS ===== func handleAdminWallets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listWallets(w, r) case http.MethodPost: createWallet(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listWallets(w http.ResponseWriter, r *http.Request) { rows, err := db.Query(` SELECT pw.id, pw.wallet_name, pw.network_id, pw.address, pw.address_type, pw.is_active, pw.is_primary, pw.requires_approval_above, pw.daily_limit, pw.monthly_limit, pw.created_at, pw.updated_at, pn.network_code FROM payment_wallets pw INNER JOIN payment_networks pn ON pw.network_id = pn.id ORDER BY pn.network_code, pw.wallet_name `) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } defer rows.Close() type WalletWithNetwork struct { PaymentWallet NetworkCode string `json:"networkCode"` } var wallets []WalletWithNetwork for rows.Next() { var wn WalletWithNetwork err := rows.Scan(&wn.ID, &wn.WalletName, &wn.NetworkID, &wn.Address, &wn.AddressType, &wn.IsActive, &wn.IsPrimary, &wn.RequiresApprovalAbove, &wn.DailyLimit, &wn.MonthlyLimit, &wn.CreatedAt, &wn.UpdatedAt, &wn.NetworkCode) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } wallets = append(wallets, wn) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(wallets) } func createWallet(w http.ResponseWriter, r *http.Request) { var req struct { WalletName string `json:"walletName"` NetworkID int `json:"networkId"` Address string `json:"address"` AddressType string `json:"addressType"` IsPrimary bool `json:"isPrimary"` RequiresApprovalAbove *float64 `json:"requiresApprovalAbove"` DailyLimit *float64 `json:"dailyLimit"` MonthlyLimit *float64 `json:"monthlyLimit"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } if req.WalletName == "" || req.Address == "" { http.Error(w, "Wallet name and address are required", http.StatusBadRequest) return } if req.AddressType == "" { req.AddressType = "hot" } // If setting as primary, unset other primary wallets for this network if req.IsPrimary { _, err := db.Exec(` UPDATE payment_wallets SET is_primary = FALSE WHERE network_id = $1 AND is_primary = TRUE `, req.NetworkID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } } var walletID int err := db.QueryRow(` INSERT INTO payment_wallets (wallet_name, network_id, address, address_type, is_active, is_primary, requires_approval_above, daily_limit, monthly_limit) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8) RETURNING id `, req.WalletName, req.NetworkID, req.Address, req.AddressType, req.IsPrimary, req.RequiresApprovalAbove, req.DailyLimit, req.MonthlyLimit).Scan(&walletID) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Wallet address already exists for this network", http.StatusConflict) return } safeError(w, "database error", err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ "id": walletID, "message": "Wallet created", }) } func handleAdminWalletByID(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.Error(w, "Invalid path", http.StatusBadRequest) return } walletID, err := strconv.Atoi(parts[3]) if err != nil { http.Error(w, "Invalid wallet ID", http.StatusBadRequest) return } switch r.Method { case http.MethodPut: updateWallet(w, r, walletID) case http.MethodDelete: deleteWallet(w, r, walletID) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func updateWallet(w http.ResponseWriter, r *http.Request, walletID int) { var updates struct { WalletName *string `json:"walletName"` IsActive *bool `json:"isActive"` IsPrimary *bool `json:"isPrimary"` RequiresApprovalAbove *float64 `json:"requiresApprovalAbove"` DailyLimit *float64 `json:"dailyLimit"` MonthlyLimit *float64 `json:"monthlyLimit"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // If setting as primary, unset others if updates.IsPrimary != nil && *updates.IsPrimary { var networkID int err := db.QueryRow("SELECT network_id FROM payment_wallets WHERE id = $1", walletID).Scan(&networkID) if err != nil { http.Error(w, "Wallet not found", http.StatusNotFound) return } _, err = db.Exec(` UPDATE payment_wallets SET is_primary = FALSE WHERE network_id = $1 AND is_primary = TRUE AND id != $2 `, networkID, walletID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } } var setClauses []string var args []interface{} argIndex := 1 if updates.WalletName != nil { setClauses = append(setClauses, fmt.Sprintf("wallet_name = $%d", argIndex)) args = append(args, *updates.WalletName) argIndex++ } if updates.IsActive != nil { setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIndex)) args = append(args, *updates.IsActive) argIndex++ } if updates.IsPrimary != nil { setClauses = append(setClauses, fmt.Sprintf("is_primary = $%d", argIndex)) args = append(args, *updates.IsPrimary) argIndex++ } if updates.RequiresApprovalAbove != nil { setClauses = append(setClauses, fmt.Sprintf("requires_approval_above = $%d", argIndex)) args = append(args, *updates.RequiresApprovalAbove) argIndex++ } if updates.DailyLimit != nil { setClauses = append(setClauses, fmt.Sprintf("daily_limit = $%d", argIndex)) args = append(args, *updates.DailyLimit) argIndex++ } if updates.MonthlyLimit != nil { setClauses = append(setClauses, fmt.Sprintf("monthly_limit = $%d", argIndex)) args = append(args, *updates.MonthlyLimit) argIndex++ } if len(setClauses) == 0 { http.Error(w, "No fields to update", http.StatusBadRequest) return } args = append(args, walletID) query := fmt.Sprintf("UPDATE payment_wallets SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argIndex) result, err := db.Exec(query, args...) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Wallet not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Wallet updated"}) } func deleteWallet(w http.ResponseWriter, r *http.Request, walletID int) { result, err := db.Exec("DELETE FROM payment_wallets WHERE id = $1", walletID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Wallet not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // ===== DONATION HANDLERS ===== func handleDonationCampaigns(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listCampaigns(w, r) case http.MethodPost: // Require admin for creating campaigns authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "Authorization required", http.StatusUnauthorized) return } // Wrap with auth middleware manually authMiddleware(func(w http.ResponseWriter, r *http.Request) { if !hasAnyRole(r.Context(), "ADMIN") { http.Error(w, "Admin access required", http.StatusForbidden) return } createCampaign(w, r) })(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listCampaigns(w http.ResponseWriter, r *http.Request) { rows, err := db.Query(` SELECT id, campaign_id, name, slug, description, short_description, cover_image_url, goal_amount, goal_currency, raised_amount, donor_count, start_date, end_date, is_public, is_active, allow_anonymous, min_donation, max_donation, category, created_by, created_at, updated_at FROM donation_campaigns WHERE is_public = TRUE AND is_active = TRUE ORDER BY created_at DESC `) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } defer rows.Close() var campaigns []DonationCampaign for rows.Next() { var c DonationCampaign err := rows.Scan(&c.ID, &c.CampaignID, &c.Name, &c.Slug, &c.Description, &c.ShortDescription, &c.CoverImageURL, &c.GoalAmount, &c.GoalCurrency, &c.RaisedAmount, &c.DonorCount, &c.StartDate, &c.EndDate, &c.IsPublic, &c.IsActive, &c.AllowAnonymous, &c.MinDonation, &c.MaxDonation, &c.Category, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } campaigns = append(campaigns, c) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(campaigns) } func createCampaign(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) var req struct { Name string `json:"name"` Slug string `json:"slug"` Description *string `json:"description"` ShortDescription *string `json:"shortDescription"` CoverImageURL *string `json:"coverImageUrl"` GoalAmount *float64 `json:"goalAmount"` GoalCurrency string `json:"goalCurrency"` StartDate *string `json:"startDate"` EndDate *string `json:"endDate"` AllowAnonymous bool `json:"allowAnonymous"` MinDonation float64 `json:"minDonation"` MaxDonation *float64 `json:"maxDonation"` Category *string `json:"category"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } if req.Name == "" || req.Slug == "" { http.Error(w, "Name and slug are required", http.StatusBadRequest) return } if req.GoalCurrency == "" { req.GoalCurrency = "USD" } if req.MinDonation <= 0 { req.MinDonation = 1 } var campaignID int err := db.QueryRow(` INSERT INTO donation_campaigns (name, slug, description, short_description, cover_image_url, goal_amount, goal_currency, start_date, end_date, allow_anonymous, min_donation, max_donation, category, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id `, req.Name, req.Slug, req.Description, req.ShortDescription, req.CoverImageURL, req.GoalAmount, req.GoalCurrency, req.StartDate, req.EndDate, req.AllowAnonymous, req.MinDonation, req.MaxDonation, req.Category, userID).Scan(&campaignID) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Campaign slug already exists", http.StatusConflict) return } safeError(w, "database error", err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ "id": campaignID, "message": "Campaign created", }) } func handleDonationCampaignBySlug(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.Error(w, "Invalid path", http.StatusBadRequest) return } slug := parts[3] var c DonationCampaign err := db.QueryRow(` SELECT id, campaign_id, name, slug, description, short_description, cover_image_url, goal_amount, goal_currency, raised_amount, donor_count, start_date, end_date, is_public, is_active, allow_anonymous, min_donation, max_donation, category, created_by, created_at, updated_at FROM donation_campaigns WHERE slug = $1 AND is_public = TRUE AND is_active = TRUE `, slug).Scan(&c.ID, &c.CampaignID, &c.Name, &c.Slug, &c.Description, &c.ShortDescription, &c.CoverImageURL, &c.GoalAmount, &c.GoalCurrency, &c.RaisedAmount, &c.DonorCount, &c.StartDate, &c.EndDate, &c.IsPublic, &c.IsActive, &c.AllowAnonymous, &c.MinDonation, &c.MaxDonation, &c.Category, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Campaign not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(c) } func handleDonate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req CreateDonationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } if req.Amount <= 0 { http.Error(w, "Amount must be positive", http.StatusBadRequest) return } if req.Currency == "" { req.Currency = "USD" } // Validate campaign if provided if req.CampaignID != nil { var minDonation, maxDonation sql.NullFloat64 var isActive bool err := db.QueryRow(` SELECT is_active, min_donation, max_donation FROM donation_campaigns WHERE id = $1 `, *req.CampaignID).Scan(&isActive, &minDonation, &maxDonation) if err == sql.ErrNoRows { http.Error(w, "Campaign not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } if !isActive { http.Error(w, "Campaign is not active", http.StatusBadRequest) return } if minDonation.Valid && req.Amount < minDonation.Float64 { http.Error(w, fmt.Sprintf("Minimum donation is %.2f", minDonation.Float64), http.StatusBadRequest) return } if maxDonation.Valid && req.Amount > maxDonation.Float64 { http.Error(w, fmt.Sprintf("Maximum donation is %.2f", maxDonation.Float64), http.StatusBadRequest) return } } // Get donor ID from auth if available var donorID *int authHeader := r.Header.Get("Authorization") if authHeader != "" { tokenString := strings.TrimPrefix(authHeader, "Bearer ") if tokenString != authHeader { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method") } return []byte(os.Getenv("JWT_SECRET")), nil }) if err == nil && token.Valid { if claims, ok := token.Claims.(jwt.MapClaims); ok { if id, err := extractUserID(claims); err == nil { donorID = &id } } } } } // Create donation record var donationUUID string err := db.QueryRow(` INSERT INTO donations (campaign_id, donor_id, is_anonymous, donor_name, donor_message, amount, currency, amount_usd, payment_method, payment_status, network_id, token_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) RETURNING donation_id `, req.CampaignID, donorID, req.IsAnonymous, req.DonorName, req.DonorMessage, req.Amount, req.Currency, req.Amount, req.PaymentMethod, req.NetworkID, req.TokenID).Scan(&donationUUID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } resp := DonationResponse{ DonationID: donationUUID, Status: "pending", } // Process payment based on method switch req.PaymentMethod { case "stripe": // Create Stripe payment intent for donation if stripe.Key == "" { http.Error(w, "Stripe not configured", http.StatusServiceUnavailable) return } amountCents := int64(req.Amount * 100) params := &stripe.PaymentIntentParams{ Amount: stripe.Int64(amountCents), Currency: stripe.String(strings.ToLower(req.Currency)), } pi, err := paymentintent.New(params) if err != nil { http.Error(w, fmt.Sprintf("Stripe error: %v", err), http.StatusInternalServerError) return } // Update donation with Stripe reference _, err = db.Exec(` UPDATE donations SET payment_id = NULL, payment_method = 'CREDIT_CARD' WHERE donation_id = $1 `, donationUUID) if err != nil { log.Printf("Failed to update donation: %v", err) } resp.ClientSecret = &pi.ClientSecret case "crypto": if req.NetworkID == nil { http.Error(w, "Network ID required for crypto donations", http.StatusBadRequest) return } // Get wallet for this network var walletAddress string var networkCode string err := db.QueryRow(` SELECT pw.address, pn.network_code FROM payment_wallets pw INNER JOIN payment_networks pn ON pw.network_id = pn.id WHERE pw.network_id = $1 AND pw.is_active = TRUE AND pw.is_primary = TRUE `, *req.NetworkID).Scan(&walletAddress, &networkCode) if err == sql.ErrNoRows { http.Error(w, "No wallet configured for this network", http.StatusServiceUnavailable) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Get exchange rate var exchangeRate float64 = 1.0 if req.TokenID != nil { var priceUSD sql.NullFloat64 db.QueryRow(`SELECT price_usd FROM payment_tokens WHERE id = $1`, *req.TokenID).Scan(&priceUSD) if priceUSD.Valid && priceUSD.Float64 > 0 { exchangeRate = priceUSD.Float64 } } else { // Get native currency rate var nativeCurrency string db.QueryRow(`SELECT native_currency FROM payment_networks WHERE id = $1`, *req.NetworkID).Scan(&nativeCurrency) db.QueryRow(` SELECT rate FROM exchange_rates WHERE base_currency = $1 AND quote_currency = 'USD' ORDER BY fetched_at DESC LIMIT 1 `, nativeCurrency).Scan(&exchangeRate) } amountCrypto := req.Amount / exchangeRate amountCryptoStr := fmt.Sprintf("%.8f", amountCrypto) resp.ToAddress = &walletAddress resp.AmountCrypto = &amountCryptoStr resp.ExchangeRate = &exchangeRate // Update donation with crypto details _, err = db.Exec(` UPDATE donations SET payment_method = 'CRYPTO' WHERE donation_id = $1 `, donationUUID) if err != nil { log.Printf("Failed to update donation: %v", err) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func handleDonationByID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 { http.Error(w, "Invalid path", http.StatusBadRequest) return } donationUUID := parts[2] userID := r.Context().Value("user_id").(int) var d Donation var donorIDVal sql.NullInt64 err := db.QueryRow(` SELECT id, donation_id, campaign_id, donor_id, is_anonymous, donor_name, donor_email, donor_message, amount, currency, amount_usd, payment_id, payment_method, payment_status, network_id, token_id, tx_hash, fee_amount, net_amount, is_recurring, created_at, updated_at FROM donations WHERE donation_id = $1 `, donationUUID).Scan(&d.ID, &d.DonationID, &d.CampaignID, &donorIDVal, &d.IsAnonymous, &d.DonorName, &d.DonorEmail, &d.DonorMessage, &d.Amount, &d.Currency, &d.AmountUSD, &d.PaymentID, &d.PaymentMethod, &d.PaymentStatus, &d.NetworkID, &d.TokenID, &d.TxHash, &d.FeeAmount, &d.NetAmount, &d.IsRecurring, &d.CreatedAt, &d.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Donation not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } if donorIDVal.Valid { id := int(donorIDVal.Int64) d.DonorID = &id } // Authorization: user must be donor or admin isOwner := d.DonorID != nil && *d.DonorID == userID isAdmin := hasAnyRole(r.Context(), "ADMIN") if !isOwner && !isAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(d) } // ===== HELPER FUNCTIONS ===== // logPaymentAudit creates an audit log entry. func logPaymentAudit(paymentID int, invoiceID *int, action, oldStatus, newStatus string, amount float64, currency string, userID *int, r *http.Request) { ip := r.RemoteAddr userAgent := r.Header.Get("User-Agent") if len(userAgent) > 500 { userAgent = userAgent[:500] } _, err := db.Exec(` INSERT INTO payment_audit_log (payment_id, invoice_id, action, old_status, new_status, amount, currency, performed_by, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, paymentID, invoiceID, action, oldStatus, newStatus, amount, currency, userID, ip, userAgent) if err != nil { log.Printf("Failed to log payment audit: %v", err) } } // formatCryptoAmount formats a crypto amount with appropriate precision. func formatCryptoAmount(amount *big.Float, decimals int) string { // Format with enough precision prec := decimals if prec > 18 { prec = 18 } return amount.Text('f', prec) } // generatePaymentReference generates a unique payment reference. func generatePaymentReference() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } // ===== PAYPAL INTEGRATION ===== var ( paypalClientID string paypalClientSecret string paypalBaseURL string paypalAccessToken string paypalTokenExpiry time.Time ) func initPayPal() { paypalClientID = strings.TrimSpace(os.Getenv("PAYPAL_CLIENT_ID")) paypalClientSecret = strings.TrimSpace(os.Getenv("PAYPAL_CLIENT_SECRET")) paypalMode := strings.TrimSpace(os.Getenv("PAYPAL_MODE")) if paypalClientID != "" && paypalClientSecret != "" { if paypalMode == "live" { paypalBaseURL = "https://api-m.paypal.com" } else { paypalBaseURL = "https://api-m.sandbox.paypal.com" } log.Printf("PayPal integration enabled (mode: %s)", paypalMode) } else { log.Println("Warning: PAYPAL_CLIENT_ID or PAYPAL_CLIENT_SECRET not set, PayPal functionality disabled") } } func getPayPalAccessToken() (string, error) { // Return cached token if still valid if paypalAccessToken != "" && time.Now().Before(paypalTokenExpiry) { return paypalAccessToken, nil } client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest("POST", paypalBaseURL+"/v1/oauth2/token", strings.NewReader("grant_type=client_credentials")) if err != nil { return "", err } req.SetBasicAuth(paypalClientID, paypalClientSecret) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("PayPal auth failed: %s", string(body)) } var token PayPalAccessToken if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { return "", err } paypalAccessToken = token.AccessToken paypalTokenExpiry = time.Now().Add(time.Duration(token.ExpiresIn-60) * time.Second) // Expire 60s early return paypalAccessToken, nil } func createPayPalOrder(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if paypalClientID == "" { http.Error(w, "PayPal not configured", http.StatusServiceUnavailable) return } userID := r.Context().Value("user_id").(int) var req PayPalOrderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Get invoice details var inv Invoice err := db.QueryRow(` SELECT id, client_id, amount, currency, status FROM invoices WHERE id = $1 `, req.InvoiceID).Scan(&inv.ID, &inv.ClientID, &inv.Amount, &inv.Currency, &inv.Status) if err == sql.ErrNoRows { http.Error(w, "Invoice not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization check isOwner := (inv.ClientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden: you can only pay your own invoices", http.StatusForbidden) return } if inv.Status == "PAID" { http.Error(w, "Invoice is already paid", http.StatusBadRequest) return } // Get PayPal access token accessToken, err := getPayPalAccessToken() if err != nil { log.Printf("PayPal auth error: %v", err) http.Error(w, "PayPal authentication failed", http.StatusInternalServerError) return } // Create PayPal order orderPayload := map[string]interface{}{ "intent": "CAPTURE", "purchase_units": []map[string]interface{}{ { "reference_id": fmt.Sprintf("INV-%d", req.InvoiceID), "amount": map[string]interface{}{ "currency_code": inv.Currency, "value": fmt.Sprintf("%.2f", inv.Amount), }, "description": fmt.Sprintf("Payment for Invoice #%d", req.InvoiceID), }, }, "application_context": map[string]interface{}{ "return_url": os.Getenv("PAYPAL_RETURN_URL"), "cancel_url": os.Getenv("PAYPAL_CANCEL_URL"), }, } orderJSON, _ := json.Marshal(orderPayload) client := &http.Client{Timeout: 30 * time.Second} paypalReq, err := http.NewRequest("POST", paypalBaseURL+"/v2/checkout/orders", strings.NewReader(string(orderJSON))) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } paypalReq.Header.Set("Content-Type", "application/json") paypalReq.Header.Set("Authorization", "Bearer "+accessToken) resp, err := client.Do(paypalReq) if err != nil { log.Printf("PayPal request error: %v", err) http.Error(w, "PayPal request failed", http.StatusInternalServerError) return } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) log.Printf("PayPal order creation failed: %s", string(body)) http.Error(w, "Failed to create PayPal order", http.StatusInternalServerError) return } var order PayPalOrder if err := json.NewDecoder(resp.Body).Decode(&order); err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Create payment record var paymentID int err = db.QueryRow(` INSERT INTO payments (invoice_id, amount, currency, payment_method, status, transaction_id, payment_processor) VALUES ($1, $2, $3, 'PAYPAL', 'PENDING', $4, 'paypal') RETURNING id `, req.InvoiceID, inv.Amount, inv.Currency, order.ID).Scan(&paymentID) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Find approval URL var approveURL string for _, link := range order.Links { if link.Rel == "approve" { approveURL = link.Href break } } // Log audit logPaymentAudit(paymentID, &req.InvoiceID, "created", "", "PENDING", inv.Amount, inv.Currency, &userID, r) response := PayPalOrderResponse{ OrderID: order.ID, PaymentID: paymentID, ApproveURL: approveURL, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func capturePayPalPayment(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if paypalClientID == "" { http.Error(w, "PayPal not configured", http.StatusServiceUnavailable) return } userID := r.Context().Value("user_id").(int) var req PayPalCaptureRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { safeError(w, "invalid request", err, http.StatusBadRequest) return } // Verify payment ownership var invoiceID, clientID int var status string err := db.QueryRow(` SELECT p.invoice_id, i.client_id, p.status FROM payments p INNER JOIN invoices i ON p.invoice_id = i.id WHERE p.id = $1 AND p.transaction_id = $2 `, req.PaymentID, req.OrderID).Scan(&invoiceID, &clientID, &status) if err == sql.ErrNoRows { http.Error(w, "Payment not found", http.StatusNotFound) return } else if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } // Authorization check isOwner := (clientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } if status != "PENDING" { http.Error(w, "Payment is not pending", http.StatusBadRequest) return } // Get PayPal access token accessToken, err := getPayPalAccessToken() if err != nil { log.Printf("PayPal auth error: %v", err) http.Error(w, "PayPal authentication failed", http.StatusInternalServerError) return } // Capture the PayPal order client := &http.Client{Timeout: 30 * time.Second} captureURL := fmt.Sprintf("%s/v2/checkout/orders/%s/capture", paypalBaseURL, req.OrderID) paypalReq, err := http.NewRequest("POST", captureURL, nil) if err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } paypalReq.Header.Set("Content-Type", "application/json") paypalReq.Header.Set("Authorization", "Bearer "+accessToken) resp, err := client.Do(paypalReq) if err != nil { log.Printf("PayPal capture error: %v", err) http.Error(w, "PayPal capture failed", http.StatusInternalServerError) return } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) log.Printf("PayPal capture failed: %s", string(body)) http.Error(w, "Failed to capture PayPal payment", http.StatusInternalServerError) return } var captureResult struct { ID string `json:"id"` Status string `json:"status"` } if err := json.NewDecoder(resp.Body).Decode(&captureResult); err != nil { safeError(w, "database error", err, http.StatusInternalServerError) return } if captureResult.Status == "COMPLETED" { // Update payment status _, err = db.Exec(` UPDATE payments SET status = 'COMPLETED', processed_at = NOW() WHERE id = $1 `, req.PaymentID) if err != nil { log.Printf("Failed to update payment: %v", err) } // Update invoice status _, err = db.Exec(` UPDATE invoices SET status = 'PAID', paid_date = CURRENT_DATE WHERE id = $1 `, invoiceID) if err != nil { log.Printf("Failed to update invoice: %v", err) } // Log audit logPaymentAudit(req.PaymentID, &invoiceID, "captured", "PENDING", "COMPLETED", 0, "", &userID, r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "status": "COMPLETED", "paymentId": req.PaymentID, "message": "Payment captured successfully", }) } else { // Update payment status to failed _, err = db.Exec(` UPDATE payments SET status = 'FAILED' WHERE id = $1 `, req.PaymentID) http.Error(w, fmt.Sprintf("Payment capture status: %s", captureResult.Status), http.StatusBadRequest) } } func handlePayPalWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } webhookID := strings.TrimSpace(os.Getenv("PAYPAL_WEBHOOK_ID")) if webhookID == "" { log.Println("ERROR: PAYPAL_WEBHOOK_ID not configured") http.Error(w, "Webhook not configured", http.StatusServiceUnavailable) return } payload, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } // Parse the webhook event var event struct { ID string `json:"id"` EventType string `json:"event_type"` Resource json.RawMessage `json:"resource"` ResourceType string `json:"resource_type"` } if err := json.Unmarshal(payload, &event); err != nil { http.Error(w, "Invalid webhook payload", http.StatusBadRequest) return } log.Printf("PayPal webhook received: %s", event.EventType) // Process based on event type switch event.EventType { case "PAYMENT.CAPTURE.COMPLETED": var resource struct { ID string `json:"id"` SupplementaryData struct { RelatedIDs struct { OrderID string `json:"order_id"` } `json:"related_ids"` } `json:"supplementary_data"` } if err := json.Unmarshal(event.Resource, &resource); err != nil { log.Printf("Failed to parse PayPal resource: %v", err) http.Error(w, "Invalid resource", http.StatusBadRequest) return } // Update payment by order ID result, err := db.Exec(` UPDATE payments SET status = 'COMPLETED', processed_at = NOW() WHERE transaction_id = $1 AND payment_processor = 'paypal' AND status = 'PENDING' `, resource.SupplementaryData.RelatedIDs.OrderID) if err != nil { log.Printf("Failed to update payment: %v", err) } else { rowsAffected, _ := result.RowsAffected() if rowsAffected > 0 { // Get invoice ID and update var invoiceID int err = db.QueryRow(` SELECT invoice_id FROM payments WHERE transaction_id = $1 AND payment_processor = 'paypal' `, resource.SupplementaryData.RelatedIDs.OrderID).Scan(&invoiceID) if err == nil { _, err = db.Exec(` UPDATE invoices SET status = 'PAID', paid_date = CURRENT_DATE WHERE id = $1 `, invoiceID) if err != nil { log.Printf("Failed to update invoice: %v", err) } } log.Printf("PayPal payment completed for order: %s", resource.SupplementaryData.RelatedIDs.OrderID) } } case "PAYMENT.CAPTURE.DENIED": var resource struct { ID string `json:"id"` } if err := json.Unmarshal(event.Resource, &resource); err == nil { _, err = db.Exec(` UPDATE payments SET status = 'FAILED' WHERE transaction_id = $1 AND payment_processor = 'paypal' `, resource.ID) if err != nil { log.Printf("Failed to update failed payment: %v", err) } } case "PAYMENT.CAPTURE.REFUNDED": var resource struct { ID string `json:"id"` } if err := json.Unmarshal(event.Resource, &resource); err == nil { _, err = db.Exec(` UPDATE payments SET status = 'REFUNDED' WHERE transaction_id = $1 AND payment_processor = 'paypal' `, resource.ID) if err != nil { log.Printf("Failed to update refunded payment: %v", err) } } default: log.Printf("Unhandled PayPal webhook event: %s", event.EventType) } w.WriteHeader(http.StatusOK) }