Files
web-hosts/domains/coppertone.tech/backend/functions/payment-service/main.go
2025-12-26 13:38:04 +01:00

3315 lines
102 KiB
Go

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 <token>", 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)
}