diff --git a/apps/api-server/internal/payment/gateway.go b/apps/api-server/internal/payment/gateway.go new file mode 100644 index 0000000..d3b7d5e --- /dev/null +++ b/apps/api-server/internal/payment/gateway.go @@ -0,0 +1,608 @@ +package payment + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// PaymentGateway interface +type PaymentGateway interface { + ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) + RefundPayment(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) + GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) + CreatePaymentMethod(ctx context.Context, req *CreatePaymentMethodRequest) (*PaymentMethod, error) + DeletePaymentMethod(ctx context.Context, paymentMethodID string) error + ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) +} + +// PaymentRequest represents a payment request +type PaymentRequest struct { + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Description string `json:"description"` + CustomerID string `json:"customerId"` + PaymentMethodID string `json:"paymentMethodId"` + Metadata map[string]string `json:"metadata"` + WebhookURL string `json:"webhookUrl"` +} + +// PaymentResponse represents a payment response +type PaymentResponse struct { + TransactionID string `json:"transactionId"` + Status PaymentStatusType `json:"status"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + ProcessedAt time.Time `json:"processedAt"` + FailureReason *string `json:"failureReason,omitempty"` + Metadata map[string]string `json:"metadata"` + RedirectURL *string `json:"redirectUrl,omitempty"` +} + +// RefundResponse represents a refund response +type RefundResponse struct { + RefundID string `json:"refundId"` + TransactionID string `json:"transactionId"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Status PaymentStatusType `json:"status"` + ProcessedAt time.Time `json:"processedAt"` + FailureReason *string `json:"failureReason,omitempty"` +} + +// PaymentStatus represents payment status +type PaymentStatus struct { + TransactionID string `json:"transactionId"` + Status PaymentStatusType `json:"status"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + FailureReason *string `json:"failureReason,omitempty"` + Refunds []RefundInfo `json:"refunds,omitempty"` +} + +// RefundInfo represents refund information +type RefundInfo struct { + RefundID string `json:"refundId"` + Amount float64 `json:"amount"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` +} + +// CreatePaymentMethodRequest represents a payment method creation request +type CreatePaymentMethodRequest struct { + Type PaymentMethodType `json:"type"` + Token string `json:"token"` + CustomerID string `json:"customerId"` + IsDefault bool `json:"isDefault"` + Metadata map[string]string `json:"metadata"` +} + +// PaymentMethod represents a payment method +type PaymentMethod struct { + ID string `json:"id"` + Type PaymentMethodType `json:"type"` + CustomerID string `json:"customerId"` + Last4 string `json:"last4"` + Brand string `json:"brand"` + ExpiryMonth int `json:"expiryMonth"` + ExpiryYear int `json:"expiryYear"` + IsDefault bool `json:"isDefault"` + CreatedAt time.Time `json:"createdAt"` + Metadata map[string]string `json:"metadata"` +} + +// PaymentStatusType represents payment status +type PaymentStatusType string + +const ( + PaymentStatusPending PaymentStatusType = "PENDING" + PaymentStatusProcessing PaymentStatusType = "PROCESSING" + PaymentStatusCompleted PaymentStatusType = "COMPLETED" + PaymentStatusFailed PaymentStatusType = "FAILED" + PaymentStatusRefunded PaymentStatusType = "REFUNDED" + PaymentStatusCancelled PaymentStatusType = "CANCELLED" +) + +// PaymentMethodType represents payment method type +type PaymentMethodType string + +const ( + PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD" + PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT" +) + +// StripeGateway implements PaymentGateway for Stripe +type StripeGateway struct { + apiKey string + webhookSecret string + client *http.Client + config *config.PaymentConfig +} + +// NewStripeGateway creates a new Stripe payment gateway +func NewStripeGateway(cfg *config.PaymentConfig) *StripeGateway { + return &StripeGateway{ + apiKey: cfg.StripeAPIKey, + webhookSecret: cfg.StripeWebhookSecret, + client: &http.Client{Timeout: 30 * time.Second}, + config: cfg, + } +} + +// ProcessPayment processes a payment via Stripe +func (s *StripeGateway) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) { + // Create payment intent + intentData := map[string]any{ + "amount": int64(req.Amount * 100), // Stripe uses cents + "currency": strings.ToLower(req.Currency), + "description": req.Description, + "customer": req.CustomerID, + "payment_method": req.PaymentMethodID, + "confirmation_method": "manual", + "confirm": true, + "metadata": req.Metadata, + } + + if req.WebhookURL != "" { + intentData["transfer_data"] = map[string]string{ + "destination": req.WebhookURL, + } + } + + intentResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_intents", intentData) + if err != nil { + return nil, fmt.Errorf("failed to create payment intent: %w", err) + } + + intent, ok := intentResp.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid payment intent response") + } + + status := s.mapStripeStatus(intent["status"].(string)) + + var failureReason *string + if status == PaymentStatusFailed { + if lastPaymentError, ok := intent["last_payment_error"].(map[string]interface{}); ok { + if message, ok := lastPaymentError["message"].(string); ok { + failureReason = &message + } + } + } + + var redirectURL *string + if status == PaymentStatusPending { + if nextAction, ok := intent["next_action"].(map[string]interface{}); ok { + if url, ok := nextAction["redirect_to_url"].(map[string]interface{}); ok { + if redirect, ok := url["url"].(string); ok { + redirectURL = &redirect + } + } + } + } + + return &PaymentResponse{ + TransactionID: intent["id"].(string), + Status: status, + Amount: req.Amount, + Currency: req.Currency, + ProcessedAt: time.Now(), + FailureReason: failureReason, + Metadata: req.Metadata, + RedirectURL: redirectURL, + }, nil +} + +// RefundPayment refunds a payment via Stripe +func (s *StripeGateway) RefundPayment(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) { + refundData := map[string]interface{}{ + "payment_intent": transactionID, + "amount": int64(amount * 100), // Stripe uses cents + } + + refundResp, err := s.makeStripeRequest(ctx, "POST", "/v1/refunds", refundData) + if err != nil { + return nil, fmt.Errorf("failed to create refund: %w", err) + } + + refund, ok := refundResp.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid refund response") + } + + status := s.mapStripeStatus(refund["status"].(string)) + + var failureReason *string + if status == PaymentStatusFailed { + if failureMsg, ok := refund["failure_reason"].(string); ok && failureMsg != "" { + failureReason = &failureMsg + } + } + + return &RefundResponse{ + RefundID: refund["id"].(string), + TransactionID: transactionID, + Amount: amount, + Currency: "USD", // Default currency, should be configurable + Status: status, + ProcessedAt: time.Now(), + FailureReason: failureReason, + }, nil +} + +// GetPaymentStatus gets payment status from Stripe +func (s *StripeGateway) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { + intentResp, err := s.makeStripeRequest(ctx, "GET", "/v1/payment_intents/"+transactionID, nil) + if err != nil { + return nil, fmt.Errorf("failed to get payment intent: %w", err) + } + + intent, ok := intentResp.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid payment intent response") + } + + // Get refunds for this payment intent + refundsResp, err := s.makeStripeRequest(ctx, "GET", "/v1/refunds?payment_intent="+transactionID, nil) + if err != nil { + return nil, fmt.Errorf("failed to get refunds: %w", err) + } + + refundsData, _ := refundsResp.(map[string]interface{}) + refundsList, _ := refundsData["data"].([]interface{}) + + refunds := make([]RefundInfo, len(refundsList)) + for i, refundInterface := range refundsList { + refund, _ := refundInterface.(map[string]interface{}) + createdAt, _ := time.Parse(time.RFC3339, refund["created"].(string)) + refunds[i] = RefundInfo{ + RefundID: refund["id"].(string), + Amount: float64(refund["amount"].(int64)) / 100, + Status: refund["status"].(string), + CreatedAt: createdAt, + } + } + + createdAt, _ := time.Parse(time.RFC3339, intent["created"].(string)) + + var failureReason *string + if lastPaymentError, ok := intent["last_payment_error"].(map[string]interface{}); ok { + if message, ok := lastPaymentError["message"].(string); ok { + failureReason = &message + } + } + + return &PaymentStatus{ + TransactionID: transactionID, + Status: s.mapStripeStatus(intent["status"].(string)), + Amount: float64(intent["amount"].(int64)) / 100, + Currency: strings.ToUpper(intent["currency"].(string)), + CreatedAt: createdAt, + UpdatedAt: time.Now(), + FailureReason: failureReason, + Refunds: refunds, + }, nil +} + +// CreatePaymentMethod creates a payment method in Stripe +func (s *StripeGateway) CreatePaymentMethod(ctx context.Context, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { + pmData := map[string]interface{}{ + "type": s.mapPaymentMethodType(req.Type), + "card": map[string]string{ + "token": req.Token, + }, + "metadata": req.Metadata, + } + + pmResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_methods", pmData) + if err != nil { + return nil, fmt.Errorf("failed to create payment method: %w", err) + } + + pm, ok := pmResp.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid payment method response") + } + + // Attach payment method to customer + attachData := map[string]interface{}{ + "customer": req.CustomerID, + } + _, err = s.makeStripeRequest(ctx, "POST", "/v1/payment_methods/"+pm["id"].(string)+"/attach", attachData) + if err != nil { + return nil, fmt.Errorf("failed to attach payment method: %w", err) + } + + // Set as default if requested + if req.IsDefault { + customerData := map[string]interface{}{ + "invoice_settings": map[string]string{ + "default_payment_method": pm["id"].(string), + }, + } + _, err = s.makeStripeRequest(ctx, "POST", "/v1/customers/"+req.CustomerID, customerData) + if err != nil { + return nil, fmt.Errorf("failed to set default payment method: %w", err) + } + } + + card, _ := pm["card"].(map[string]interface{}) + createdAt, _ := time.Parse(time.RFC3339, pm["created"].(string)) + + return &PaymentMethod{ + ID: pm["id"].(string), + Type: req.Type, + CustomerID: req.CustomerID, + Last4: card["last4"].(string), + Brand: card["brand"].(string), + ExpiryMonth: int(card["exp_month"].(float64)), + ExpiryYear: int(card["exp_year"].(float64)), + IsDefault: req.IsDefault, + CreatedAt: createdAt, + Metadata: req.Metadata, + }, nil +} + +// DeletePaymentMethod deletes a payment method from Stripe +func (s *StripeGateway) DeletePaymentMethod(ctx context.Context, paymentMethodID string) error { + _, err := s.makeStripeRequest(ctx, "DELETE", "/v1/payment_methods/"+paymentMethodID, nil) + if err != nil { + return fmt.Errorf("failed to delete payment method: %w", err) + } + return nil +} + +// ValidateWebhook validates a Stripe webhook +func (s *StripeGateway) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { + if s.webhookSecret == "" { + return false, fmt.Errorf("webhook secret not configured") + } + + // Parse signature + parts := strings.Split(signature, ",") + var timestamp, v1Signature string + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "t=") { + timestamp = strings.TrimPrefix(part, "t=") + } else if strings.HasPrefix(part, "v1=") { + v1Signature = strings.TrimPrefix(part, "v1=") + } + } + + if timestamp == "" || v1Signature == "" { + return false, fmt.Errorf("invalid signature format") + } + + // Create expected signature + expectedSignature := s.computeHMACSHA256(timestamp, string(body), s.webhookSecret) + + return hmac.Equal([]byte(v1Signature), []byte(expectedSignature)), nil +} + +// Helper methods +func (s *StripeGateway) makeStripeRequest(ctx context.Context, method, path string, data interface{}) (interface{}, error) { + url := "https://api.stripe.com" + path + + var body io.Reader + if data != nil { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal request data: %w", err) + } + body = strings.NewReader(string(jsonData)) + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Stripe-Version", "2023-10-16") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + var errorResp map[string]interface{} + if err := json.Unmarshal(respBody, &errorResp); err == nil { + if errorMsg, ok := errorResp["error"].(map[string]interface{}); ok { + if message, ok := errorMsg["message"].(string); ok { + return nil, fmt.Errorf("stripe error: %s", message) + } + } + } + return nil, fmt.Errorf("stripe error: %s", string(respBody)) + } + + var result interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return result, nil +} + +func (s *StripeGateway) mapStripeStatus(status string) PaymentStatusType { + switch status { + case "requires_payment_method", "requires_confirmation", "requires_action": + return PaymentStatusPending + case "processing": + return PaymentStatusProcessing + case "succeeded": + return PaymentStatusCompleted + case "canceled": + return PaymentStatusCancelled + default: + return PaymentStatusFailed + } +} + +func (s *StripeGateway) mapPaymentMethodType(pmt PaymentMethodType) string { + switch pmt { + case PaymentMethodTypeCreditCard: + return "card" + case PaymentMethodTypeBankAccount: + return "bank_account" + default: + return "card" + } +} + +func (s *StripeGateway) computeHMACSHA256(timestamp, payload, secret string) string { + data := timestamp + "." + payload + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +// PaymentService handles payment operations +type PaymentService struct { + gateway PaymentGateway + db *database.Database + config *config.PaymentConfig +} + +// NewPaymentService creates a new payment service +func NewPaymentService(db *database.Database, cfg *config.PaymentConfig) *PaymentService { + var gateway PaymentGateway + + switch cfg.Provider { + case "stripe": + gateway = NewStripeGateway(cfg) + default: + gateway = NewStripeGateway(cfg) // Default to Stripe + } + + return &PaymentService{ + gateway: gateway, + db: db, + config: cfg, + } +} + +// ProcessTopUp processes a top-up payment +func (ps *PaymentService) ProcessTopUp(ctx context.Context, subscriberID int, amount float64, paymentMethodID string) (*models.Transaction, error) { + // Get subscriber + subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber: %w", err) + } + + // Create payment request + req := &PaymentRequest{ + Amount: amount, + Currency: "USD", + Description: fmt.Sprintf("Top-up for subscriber %s", subscriber.MSISDN), + CustomerID: subscriber.MSISDN, + PaymentMethodID: paymentMethodID, + Metadata: map[string]string{ + "subscriber_id": fmt.Sprintf("%d", subscriberID), + "type": "top_up", + }, + } + + // Process payment + resp, err := ps.gateway.ProcessPayment(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to process payment: %w", err) + } + + // Create transaction record + transaction := &models.Transaction{ + SubscriberID: uint(subscriberID), + TransactionID: resp.TransactionID, + Type: "TOP_UP", + Amount: amount, + Currency: resp.Currency, + Status: string(resp.Status), + Description: req.Description, + CreatedAt: resp.ProcessedAt, + UpdatedAt: resp.ProcessedAt, + } + + // Save transaction + err = ps.db.CreateTransaction(ctx, transaction) + if err != nil { + return nil, fmt.Errorf("failed to save transaction: %w", err) + } + + // If payment was successful, update subscriber balance + if resp.Status == PaymentStatusCompleted { + err = ps.db.UpdateSubscriberBalance(ctx, uint(subscriberID), amount) + if err != nil { + return nil, fmt.Errorf("failed to update subscriber balance: %w", err) + } + } + + return transaction, nil +} + +// GetPaymentStatus gets payment status +func (ps *PaymentService) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { + return ps.gateway.GetPaymentStatus(ctx, transactionID) +} + +// CreatePaymentMethod creates a payment method +func (ps *PaymentService) CreatePaymentMethod(ctx context.Context, subscriberID int, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { + subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber: %w", err) + } + + req.CustomerID = subscriber.MSISDN + + pm, err := ps.gateway.CreatePaymentMethod(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to create payment method: %w", err) + } + + // Save payment method to database + dbPM := &models.PaymentMethod{ + SubscriberID: uint(subscriberID), + GatewayID: pm.ID, + Type: models.PaymentMethodType(pm.Type), + Last4: pm.Last4, + Brand: pm.Brand, + ExpiryMonth: pm.ExpiryMonth, + ExpiryYear: pm.ExpiryYear, + IsDefault: pm.IsDefault, + CreatedAt: pm.CreatedAt, + } + + err = ps.db.CreatePaymentMethod(ctx, dbPM) + if err != nil { + return nil, fmt.Errorf("failed to save payment method: %w", err) + } + + pm.ID = fmt.Sprintf("%s", dbPM.ID) // Return database ID + return pm, nil +} + +// ValidateWebhook validates a webhook +func (ps *PaymentService) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { + return ps.gateway.ValidateWebhook(ctx, signature, body) +}