Revert "App layer"

This commit is contained in:
Nutcase
2026-04-17 15:59:31 +03:00
committed by GitHub
parent 5d74888852
commit efed7e936c
20 changed files with 32 additions and 4881 deletions
-620
View File
@@ -1,620 +0,0 @@
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"`
}
// PaymentMethodDetails represents payment method details retrieved from token
type PaymentMethodDetails struct {
PaymentMethodID string `json:"paymentMethodId"`
Type string `json:"type"`
Last4 string `json:"last4"`
Brand string `json:"brand"`
ExpiryMonth int `json:"expiryMonth"`
ExpiryYear int `json:"expiryYear"`
Fingerprint string `json:"fingerprint"`
CreatedAt time.Time `json:"createdAt"`
}
// 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)
}
@@ -1,288 +0,0 @@
package gateways
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/stripe/stripe-go/v82"
"github.com/stripe/stripe-go/v82/customer"
"github.com/stripe/stripe-go/v82/invoice"
"github.com/stripe/stripe-go/v82/invoiceitem"
"github.com/stripe/stripe-go/v82/paymentintent"
"github.com/stripe/stripe-go/v82/paymentmethod"
"github.com/stripe/stripe-go/v82/refund"
"github.com/stripe/stripe-go/v82/webhook"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/payment"
)
// StripeGateway implements payment processing via Stripe
type StripeGateway struct {
secretKey string
webhookSecret string
client *http.Client
}
// NewStripeGateway creates a new Stripe payment gateway
func NewStripeGateway(secretKey, webhookSecret string) *StripeGateway {
stripe.Key = secretKey
return &StripeGateway{
secretKey: secretKey,
webhookSecret: webhookSecret,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// ProcessPayment processes a payment via Stripe
func (g *StripeGateway) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) {
// Create payment intent
params := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(req.Amount * 100)), // Convert to cents
Currency: stripe.String(string(req.Currency)),
Metadata: map[string]string{
"subscriber_id": fmt.Sprintf("%d", req.SubscriberID),
"invoice_id": fmt.Sprintf("%d", req.InvoiceID),
},
}
if req.CustomerID != "" {
params.Customer = stripe.String(req.CustomerID)
}
pi, err := paymentintent.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create payment intent: %w", err)
}
return &PaymentResponse{
TransactionID: pi.ID,
Status: string(pi.Status),
ClientSecret: pi.ClientSecret,
Amount: req.Amount,
Currency: req.Currency,
CreatedAt: time.Unix(pi.Created, 0),
}, nil
}
// RetrievePaymentMethodFromToken retrieves payment method details from a Stripe token
func (g *StripeGateway) RetrievePaymentMethodFromToken(ctx context.Context, token string) (*payment.PaymentMethodDetails, error) {
// Create payment method from token
params := &stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(token),
},
}
pm, err := paymentmethod.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create payment method from token: %w", err)
}
// Extract card details
if pm.Card == nil {
return nil, fmt.Errorf("no card details found in payment method")
}
return &payment.PaymentMethodDetails{
PaymentMethodID: pm.ID,
Type: string(pm.Type),
Last4: pm.Card.Last4,
Brand: string(pm.Card.Brand),
ExpiryMonth: int(pm.Card.ExpMonth),
ExpiryYear: int(pm.Card.ExpYear),
Fingerprint: pm.Card.Fingerprint,
CreatedAt: time.Unix(pm.Created, 0),
}, nil
}
// CreateCustomer creates a Stripe customer
func (g *StripeGateway) CreateCustomer(ctx context.Context, subscriber *models.Subscriber) (*CustomerResponse, error) {
params := &stripe.CustomerParams{
Email: stripe.String(subscriber.Email),
Name: stripe.String(fmt.Sprintf("%s %s", subscriber.FirstName, subscriber.LastName)),
Metadata: map[string]string{
"subscriber_id": fmt.Sprintf("%d", subscriber.ID),
"imsi": string(subscriber.IMSI),
},
}
cust, err := customer.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create customer: %w", err)
}
return &CustomerResponse{
CustomerID: cust.ID,
Email: cust.Email,
Name: cust.Name,
CreatedAt: time.Unix(cust.Created, 0),
}, nil
}
// ProcessRefund processes a refund via Stripe
func (g *StripeGateway) ProcessRefund(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) {
params := &stripe.RefundParams{
PaymentIntent: stripe.String(transactionID),
}
if amount > 0 {
params.Amount = stripe.Int64(int64(amount * 100)) // Convert to cents
}
refund, err := refund.New(params)
if err != nil {
return nil, fmt.Errorf("failed to process refund: %w", err)
}
return &RefundResponse{
RefundID: refund.ID,
Amount: float64(refund.Amount) / 100.0,
Status: string(refund.Status),
CreatedAt: time.Unix(refund.Created, 0),
}, nil
}
// GetPaymentStatus retrieves payment status from Stripe
func (g *StripeGateway) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatusResponse, error) {
pi, err := paymentintent.Get(transactionID, nil)
if err != nil {
return nil, fmt.Errorf("failed to get payment intent: %w", err)
}
return &PaymentStatusResponse{
TransactionID: pi.ID,
Status: string(pi.Status),
Amount: float64(pi.Amount) / 100.0,
Currency: string(pi.Currency),
CreatedAt: time.Unix(pi.Created, 0),
}, nil
}
// ValidateWebhook validates and processes Stripe webhooks
func (g *StripeGateway) ValidateWebhook(ctx context.Context, signatureHeader string, body []byte) (*WebhookEvent, error) {
event, err := webhook.ConstructEvent(body, signatureHeader, g.webhookSecret)
if err != nil {
return nil, fmt.Errorf("webhook validation failed: %w", err)
}
return &WebhookEvent{
Type: string(event.Type),
EventID: event.ID,
Created: time.Unix(event.Created, 0),
Data: event.Data.Raw,
}, nil
}
// CreateInvoice creates a Stripe invoice
func (g *StripeGateway) CreateInvoice(ctx context.Context, customerID string, items []InvoiceItem) (*InvoiceResponse, error) {
// Create invoice items first
for _, item := range items {
itemParams := &stripe.InvoiceItemParams{
Customer: stripe.String(customerID),
Amount: stripe.Int64(int64(item.Amount * 100)),
Currency: stripe.String(string(item.Currency)),
Description: stripe.String(item.Description),
Quantity: stripe.Int64(int64(item.Quantity)),
}
_, err := invoiceitem.New(itemParams)
if err != nil {
return nil, fmt.Errorf("failed to create invoice item: %w", err)
}
}
// Create the invoice
params := &stripe.InvoiceParams{
Customer: stripe.String(customerID),
AutoAdvance: stripe.Bool(true),
}
inv, err := invoice.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create invoice: %w", err)
}
return &InvoiceResponse{
InvoiceID: inv.ID,
Status: string(inv.Status),
Amount: float64(inv.Total) / 100.0,
Currency: string(inv.Currency),
CreatedAt: time.Unix(inv.Created, 0),
}, nil
}
// PaymentRequest represents a payment request
type PaymentRequest struct {
SubscriberID uint `json:"subscriber_id"`
InvoiceID uint `json:"invoice_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
CustomerID string `json:"customer_id,omitempty"`
Description string `json:"description,omitempty"`
}
// PaymentResponse represents a payment response
type PaymentResponse struct {
TransactionID string `json:"transaction_id"`
Status string `json:"status"`
ClientSecret string `json:"client_secret"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerResponse represents a customer creation response
type CustomerResponse struct {
CustomerID string `json:"customer_id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
// RefundResponse represents a refund response
type RefundResponse struct {
RefundID string `json:"refund_id"`
Amount float64 `json:"amount"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// PaymentStatusResponse represents payment status response
type PaymentStatusResponse struct {
TransactionID string `json:"transaction_id"`
Status string `json:"status"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
CreatedAt time.Time `json:"created_at"`
}
// WebhookEvent represents a webhook event
type WebhookEvent struct {
Type string `json:"type"`
EventID string `json:"event_id"`
Created time.Time `json:"created"`
Data json.RawMessage `json:"data"`
}
// InvoiceItem represents an invoice line item
type InvoiceItem struct {
Description string `json:"description"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Quantity int `json:"quantity"`
}
// InvoiceResponse represents an invoice response
type InvoiceResponse struct {
InvoiceID string `json:"invoice_id"`
Status string `json:"status"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
CreatedAt time.Time `json:"created_at"`
}
-594
View File
@@ -1,594 +0,0 @@
package payment
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
)
// WebhookEvent represents a payment webhook event
type WebhookEvent struct {
ID string `json:"id"`
Type string `json:"type"`
Object string `json:"object"`
Created int64 `json:"created"`
Livemode bool `json:"livemode"`
Data map[string]interface{} `json:"data"`
}
// WebhookHandler handles payment webhooks
type WebhookHandler struct {
paymentService *PaymentService
db *database.Database
}
// NewWebhookHandler creates a new webhook handler
func NewWebhookHandler(paymentService *PaymentService, db *database.Database) *WebhookHandler {
return &WebhookHandler{
paymentService: paymentService,
db: db,
}
}
// HandleStripeWebhook handles Stripe webhooks
func (wh *WebhookHandler) HandleStripeWebhook(c *gin.Context) {
body, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
signature := c.GetHeader("Stripe-Signature")
if signature == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing signature"})
return
}
// Validate webhook
valid, err := wh.paymentService.ValidateWebhook(c.Request.Context(), signature, body)
if err != nil {
log.Printf("Webhook validation error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook"})
return
}
if !valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
return
}
// Parse event
var event WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
log.Printf("Failed to parse webhook event: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid event format"})
return
}
// Handle different event types
switch event.Type {
case "payment_intent.succeeded":
err = wh.handlePaymentSucceeded(c.Request.Context(), event)
case "payment_intent.payment_failed":
err = wh.handlePaymentFailed(c.Request.Context(), event)
case "payment_intent.canceled":
err = wh.handlePaymentCanceled(c.Request.Context(), event)
case "charge.dispute.created":
err = wh.handleDisputeCreated(c.Request.Context(), event)
default:
log.Printf("Unhandled webhook event type: %s", event.Type)
}
if err != nil {
log.Printf("Error handling webhook event %s: %v", event.Type, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process webhook"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "received"})
}
// handlePaymentSucceeded handles successful payment events
func (wh *WebhookHandler) handlePaymentSucceeded(ctx context.Context, event WebhookEvent) error {
paymentIntent, ok := event.Data["object"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid payment intent object")
}
transactionID, ok := paymentIntent["id"].(string)
if !ok {
return fmt.Errorf("missing transaction ID")
}
amount := float64(paymentIntent["amount"].(int64)) / 100
currency := paymentIntent["currency"].(string)
// Get transaction from database
transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID)
if err != nil {
return fmt.Errorf("failed to get transaction: %w", err)
}
// Update transaction status
transaction.Status = "COMPLETED"
transaction.UpdatedAt = time.Now()
err = wh.db.UpdateTransaction(ctx, transaction)
if err != nil {
return fmt.Errorf("failed to update transaction: %w", err)
}
// If it's a top-up, update subscriber balance
if transaction.Type == "TOP_UP" {
err = wh.db.UpdateSubscriberBalance(ctx, transaction.SubscriberID, amount)
if err != nil {
return fmt.Errorf("failed to update subscriber balance: %w", err)
}
// Send notification to subscriber
err = wh.sendTopUpNotification(ctx, transaction.SubscriberID, amount, currency)
if err != nil {
log.Printf("Failed to send top-up notification: %v", err)
}
}
log.Printf("Payment succeeded: %s, amount: %.2f %s", transactionID, amount, currency)
return nil
}
// handlePaymentFailed handles failed payment events
func (wh *WebhookHandler) handlePaymentFailed(ctx context.Context, event WebhookEvent) error {
paymentIntent, ok := event.Data["object"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid payment intent object")
}
transactionID, ok := paymentIntent["id"].(string)
if !ok {
return fmt.Errorf("missing transaction ID")
}
// Get transaction from database
transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID)
if err != nil {
return fmt.Errorf("failed to get transaction: %w", err)
}
// Update transaction status
transaction.Status = "FAILED"
transaction.UpdatedAt = time.Now()
// Add failure reason if available
if lastPaymentError, ok := paymentIntent["last_payment_error"].(map[string]interface{}); ok {
if message, ok := lastPaymentError["message"].(string); ok {
transaction.Description += " - Failed: " + message
}
}
err = wh.db.UpdateTransaction(ctx, transaction)
if err != nil {
return fmt.Errorf("failed to update transaction: %w", err)
}
// Create alert for failed payment
subID := int(transaction.SubscriberID)
alert := &models.Alert{
SubscriberID: &subID,
Type: models.AlertTypePaymentFailed,
Severity: models.AlertSeverityMedium,
Message: fmt.Sprintf("Payment of %.2f %s failed", transaction.Amount, transaction.Currency),
Resolved: false,
Timestamp: time.Now(),
}
err = wh.db.CreateAlert(ctx, alert)
if err != nil {
log.Printf("Failed to create payment failure alert: %v", err)
}
log.Printf("Payment failed: %s", transactionID)
return nil
}
// handlePaymentCanceled handles canceled payment events
func (wh *WebhookHandler) handlePaymentCanceled(ctx context.Context, event WebhookEvent) error {
paymentIntent, ok := event.Data["object"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid payment intent object")
}
transactionID, ok := paymentIntent["id"].(string)
if !ok {
return fmt.Errorf("missing transaction ID")
}
// Get transaction from database
transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID)
if err != nil {
return fmt.Errorf("failed to get transaction: %w", err)
}
// Update transaction status
transaction.Status = "CANCELLED"
transaction.UpdatedAt = time.Now()
err = wh.db.UpdateTransaction(ctx, transaction)
if err != nil {
return fmt.Errorf("failed to update transaction: %w", err)
}
log.Printf("Payment canceled: %s", transactionID)
return nil
}
// handleDisputeCreated handles dispute events
func (wh *WebhookHandler) handleDisputeCreated(ctx context.Context, event WebhookEvent) error {
dispute, ok := event.Data["object"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid dispute object")
}
chargeID, ok := dispute["charge"].(string)
if !ok {
return fmt.Errorf("missing charge ID")
}
amount := float64(dispute["amount"].(int64)) / 100
reason, _ := dispute["reason"].(string)
// Find transaction by charge ID
transaction, err := wh.db.GetTransactionByChargeID(ctx, chargeID)
if err != nil {
return fmt.Errorf("failed to find transaction for charge %s: %w", chargeID, err)
}
// Create high-priority alert for dispute
dispSubID := int(transaction.SubscriberID)
alert := &models.Alert{
SubscriberID: &dispSubID,
Type: models.AlertTypePaymentFailed,
Severity: models.AlertSeverityHigh,
Message: fmt.Sprintf("Payment dispute created for %.2f %s. Reason: %s", amount, transaction.Currency, reason),
Resolved: false,
Timestamp: time.Now(),
}
err = wh.db.CreateAlert(ctx, alert)
if err != nil {
return fmt.Errorf("failed to create dispute alert: %w", err)
}
log.Printf("Dispute created for charge %s, amount: %.2f", chargeID, amount)
return nil
}
// sendTopUpNotification sends a notification for successful top-up
func (wh *WebhookHandler) sendTopUpNotification(ctx context.Context, subscriberID uint, amount float64, currency string) error {
// Get subscriber details
subscriber, err := wh.db.GetSubscriber(ctx, subscriberID)
if err != nil {
return fmt.Errorf("failed to get subscriber: %w", err)
}
// Create notification
notification := &models.Notification{
SubscriberID: subscriberID,
Type: "PAYMENT_SUCCESS",
Title: "Top-up Successful",
Message: fmt.Sprintf("Your account has been topped up with %.2f %s. New balance: %.2f %s",
amount, currency, subscriber.Balance, currency),
Read: false,
CreatedAt: time.Now(),
}
err = wh.db.CreateNotification(ctx, notification)
if err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
// Send email notification (implementation would depend on email service)
// This is a placeholder for email sending logic
log.Printf("Top-up notification sent to subscriber %d: %.2f %s", subscriberID, amount, currency)
return nil
}
// PaymentController handles payment-related HTTP endpoints
type PaymentController struct {
paymentService *PaymentService
db *database.Database
}
// NewPaymentController creates a new payment controller
func NewPaymentController(paymentService *PaymentService, db *database.Database) *PaymentController {
return &PaymentController{
paymentService: paymentService,
db: db,
}
}
// SetupRoutes sets up payment routes
func (pc *PaymentController) SetupRoutes(router *gin.RouterGroup) {
payments := router.Group("/payments")
{
payments.POST("/top-up", pc.ProcessTopUp)
payments.POST("/payment-methods", pc.CreatePaymentMethod)
payments.DELETE("/payment-methods/:id", pc.DeletePaymentMethod)
payments.GET("/payment-methods", pc.ListPaymentMethods)
payments.GET("/transactions", pc.ListTransactions)
payments.GET("/transactions/:id", pc.GetTransaction)
payments.POST("/transactions/:id/refund", pc.RefundTransaction)
}
webhook := router.Group("/webhooks")
{
webhook.POST("/stripe", pc.HandleStripeWebhook)
}
}
// ProcessTopUp processes a top-up payment
func (pc *PaymentController) ProcessTopUp(c *gin.Context) {
var req struct {
Amount float64 `json:"amount" binding:"required,min=0.01"`
PaymentMethodID string `json:"paymentMethodId" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get subscriber ID from context (would be set by auth middleware)
subscriberID := c.GetInt("subscriberID")
if subscriberID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
transaction, err := pc.paymentService.ProcessTopUp(c.Request.Context(), subscriberID, req.Amount, req.PaymentMethodID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"transaction": transaction,
"message": "Top-up processed successfully",
})
}
// CreatePaymentMethod creates a new payment method
func (pc *PaymentController) CreatePaymentMethod(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
Token string `json:"token" binding:"required"`
IsDefault bool `json:"isDefault"`
Metadata map[string]string `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
subscriberID := c.GetInt("subscriberID")
if subscriberID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
createReq := &CreatePaymentMethodRequest{
Type: PaymentMethodType(req.Type),
Token: req.Token,
IsDefault: req.IsDefault,
Metadata: req.Metadata,
}
paymentMethod, err := pc.paymentService.CreatePaymentMethod(c.Request.Context(), subscriberID, createReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"paymentMethod": paymentMethod,
"message": "Payment method created successfully",
})
}
// DeletePaymentMethod deletes a payment method
func (pc *PaymentController) DeletePaymentMethod(c *gin.Context) {
paymentMethodID := c.Param("id")
if paymentMethodID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Payment method ID required"})
return
}
subscriberID := c.GetInt("subscriberID")
if subscriberID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Verify payment method belongs to subscriber
pm, err := pc.db.GetPaymentMethod(c.Request.Context(), paymentMethodID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Payment method not found"})
return
}
if pm.SubscriberID != uint(subscriberID) {
c.JSON(http.StatusForbidden, gin.H{"error": "Payment method does not belong to subscriber"})
return
}
err = pc.paymentService.gateway.DeletePaymentMethod(c.Request.Context(), pm.GatewayID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Delete from database
err = pc.db.DeletePaymentMethod(c.Request.Context(), paymentMethodID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Payment method deleted successfully"})
}
// ListPaymentMethods lists payment methods for a subscriber
func (pc *PaymentController) ListPaymentMethods(c *gin.Context) {
subscriberID := c.GetInt("subscriberID")
if subscriberID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
paymentMethods, err := pc.db.ListPaymentMethods(c.Request.Context(), uint(subscriberID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"paymentMethods": paymentMethods})
}
// ListTransactions lists transactions for a subscriber
func (pc *PaymentController) ListTransactions(c *gin.Context) {
subscriberID := c.GetInt("subscriberID")
if subscriberID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
transactions, err := pc.db.ListTransactions(c.Request.Context(), uint(subscriberID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"transactions": transactions})
}
// GetTransaction gets a specific transaction
func (pc *PaymentController) GetTransaction(c *gin.Context) {
transactionID := c.Param("id")
if transactionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"})
return
}
subscriberID := c.GetInt("subscriberID")
if subscriberID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"})
return
}
if transaction.SubscriberID != uint(subscriberID) {
c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"})
return
}
c.JSON(http.StatusOK, gin.H{"transaction": transaction})
}
// RefundTransaction refunds a transaction
func (pc *PaymentController) RefundTransaction(c *gin.Context) {
transactionID := c.Param("id")
if transactionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"})
return
}
var req struct {
Amount float64 `json:"amount" binding:"required,min=0.01"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
subscriberID := c.GetInt("subscriberID")
if subscriberID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Get transaction
transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"})
return
}
if transaction.SubscriberID != uint(subscriberID) {
c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"})
return
}
if transaction.Status != "COMPLETED" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot refund non-completed transaction"})
return
}
// Process refund
refundResp, err := pc.paymentService.gateway.RefundPayment(c.Request.Context(), transaction.TransactionID, req.Amount)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Create refund record
refund := &models.Transaction{
SubscriberID: transaction.SubscriberID,
TransactionID: refundResp.RefundID,
Type: "REFUND",
Amount: req.Amount,
Currency: refundResp.Currency,
Status: string(refundResp.Status),
Description: fmt.Sprintf("Refund for transaction %s", transactionID),
ParentID: &transaction.ID,
CreatedAt: refundResp.ProcessedAt,
UpdatedAt: refundResp.ProcessedAt,
}
err = pc.db.CreateTransaction(c.Request.Context(), refund)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save refund"})
return
}
// Update subscriber balance
err = pc.db.UpdateSubscriberBalance(c.Request.Context(), transaction.SubscriberID, -req.Amount)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"})
return
}
c.JSON(http.StatusOK, gin.H{
"refund": refund,
"message": "Refund processed successfully",
})
}
// HandleStripeWebhook handles Stripe webhooks (delegates to webhook handler)
func (pc *PaymentController) HandleStripeWebhook(c *gin.Context) {
webhookHandler := NewWebhookHandler(pc.paymentService, pc.db)
webhookHandler.HandleStripeWebhook(c)
}