3315 lines
102 KiB
Go
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)
|
|
}
|