This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
+158
View File
@@ -0,0 +1,158 @@
package eshop
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
)
type PacketaService struct {
Config *config.Config
}
func NewPacketaService(cfg *config.Config) *PacketaService {
return &PacketaService{Config: cfg}
}
const packetaAPIURL = "https://www.zasilkovna.cz/api/rest"
// CreatePacket creates a new packet in Packeta system
func (s *PacketaService) CreatePacket(order *models.EshopOrder) (string, error) {
type PacketAttributes struct {
Number string `xml:"number"`
Name string `xml:"name"`
Surname string `xml:"surname"`
Email string `xml:"email"`
Phone string `xml:"phone"`
AddressID string `xml:"addressId"`
Value string `xml:"value"`
Currency string `xml:"currency"`
Weight string `xml:"weight"`
Eshop string `xml:"eshop"`
}
type CreatePacketRequest struct {
XMLName xml.Name `xml:"createPacket"`
ApiPassword string `xml:"apiPassword"`
PacketAttributes PacketAttributes `xml:"packetAttributes"`
}
// Extract shipping data
// order.ShippingDataJSON should contain the selected point ID (addressId)
// For now assuming we parse it or it is passed differently.
// In a real scenario, we would parse ShippingDataJSON to get the point ID.
addressID := "1483" // Fallback/Test ID if parsing fails. TODO: Implement proper parsing.
reqBody := CreatePacketRequest{
ApiPassword: s.Config.PacketaAPIPassword,
PacketAttributes: PacketAttributes{
Number: order.OrderNumber,
Name: order.FirstName,
Surname: order.LastName,
Email: order.Email,
Phone: "", // Phone is optional in our model but required for some carriers. TODO: Add phone to checkout.
AddressID: addressID,
Value: fmt.Sprintf("%.2f", float64(order.TotalAmountCents)/100.0),
Currency: order.Currency,
Weight: "1.0", // Default weight 1kg. TODO: Calculate from items.
Eshop: s.Config.PacketaEshopName,
},
}
respBytes, err := s.sendRequest(reqBody)
if err != nil {
return "", err
}
type CreatePacketResponse struct {
PacketId string `xml:"packetId"`
Status string `xml:"status"`
Fault string `xml:"fault"`
}
var resp CreatePacketResponse
if err := xml.Unmarshal(respBytes, &resp); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
if resp.Status != "ok" && resp.PacketId == "" {
return "", fmt.Errorf("packeta error: %s", resp.Fault)
}
return resp.PacketId, nil
}
// GetPacketLabel downloads the label PDF
func (s *PacketaService) GetPacketLabel(packetID string) ([]byte, error) {
type PacketLabelPdfRequest struct {
XMLName xml.Name `xml:"packetLabelPdf"`
ApiPassword string `xml:"apiPassword"`
PacketId string `xml:"packetId"`
Format string `xml:"format"`
Offset int `xml:"offset"`
}
reqBody := PacketLabelPdfRequest{
ApiPassword: s.Config.PacketaAPIPassword,
PacketId: packetID,
Format: "A6 on A4",
Offset: 0,
}
return s.sendRequest(reqBody)
}
// GetPacketStatus checks the status of a packet
func (s *PacketaService) GetPacketStatus(packetID string) (string, error) {
type PacketStatusRequest struct {
XMLName xml.Name `xml:"packetStatus"`
ApiPassword string `xml:"apiPassword"`
PacketId string `xml:"packetId"`
}
reqBody := PacketStatusRequest{
ApiPassword: s.Config.PacketaAPIPassword,
PacketId: packetID,
}
respBytes, err := s.sendRequest(reqBody)
if err != nil {
return "", err
}
// Simplistic parsing for status text/code
// XML response structure varies slightly but usually contains <status>...</status>
// We might need a more robust struct.
return string(respBytes), nil
}
func (s *PacketaService) sendRequest(payload interface{}) ([]byte, error) {
xmlBytes, err := xml.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(packetaAPIURL, "text/xml", bytes.NewReader(xmlBytes))
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
@@ -0,0 +1,349 @@
package eshop
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/httpclient"
)
// RevolutOAuthService handles OAuth2 authentication for both Revolut Pro and Business accounts
type RevolutOAuthService struct {
cfg *config.Config
client *http.Client
}
// RevolutOAuthToken represents the OAuth token response
type RevolutOAuthToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
Scope string `json:"scope"`
}
// RevolutAccountType represents the type of Revolut account
type RevolutAccountType string
const (
RevolutAccountTypePro RevolutAccountType = "revolut_pro"
RevolutAccountTypeBusiness RevolutAccountType = "business"
)
// RevolutOAuthConfig holds OAuth configuration for different account types
type RevolutOAuthConfig struct {
AccountType RevolutAccountType `json:"account_type"`
ClientID string `json:"client_id"`
AuthBaseURL string `json:"auth_base_url"`
TokenURL string `json:"token_url"`
APIBaseURL string `json:"api_base_url"`
}
// NewRevolutOAuthService creates a new OAuth service instance
func NewRevolutOAuthService(cfg *config.Config) *RevolutOAuthService {
return &RevolutOAuthService{
cfg: cfg,
client: httpclient.DefaultClient(),
}
}
// GetOAuthConfig returns configuration for the specified account type and environment
func (s *RevolutOAuthService) GetOAuthConfig(accountType RevolutAccountType) RevolutOAuthConfig {
isSandbox := s.cfg.RevolutEnvironment == "sandbox"
switch accountType {
case RevolutAccountTypePro:
if isSandbox {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypePro,
ClientID: "sandbox_pro_client_id", // Get from Revolut for sandbox
AuthBaseURL: "https://sandbox-checkout.revolut.com",
TokenURL: "https://sandbox-checkout.revolut.com/api/connect/oauth/token",
APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0",
}
} else {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypePro,
ClientID: "9cda975e-016c-4b49-b5c6-37d1285ba046", // Production client ID
AuthBaseURL: "https://checkout.revolut.com",
TokenURL: "https://checkout.revolut.com/api/connect/oauth/token",
APIBaseURL: "https://merchant.revolut.com/api/1.0",
}
}
case RevolutAccountTypeBusiness:
if isSandbox {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypeBusiness,
ClientID: "sandbox_business_client_id",
AuthBaseURL: "https://sandbox-business.revolut.com",
TokenURL: "https://sandbox-business.revolut.com/api/1.0/auth/token",
APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0",
}
} else {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypeBusiness,
ClientID: "diiToLZlMJOPtWhdFTxQ", // Production business client ID
AuthBaseURL: "https://business.revolut.com",
TokenURL: "https://b2b.revolut.com/api/1.0/auth/token",
APIBaseURL: "https://merchant.revolut.com/api/1.0",
}
}
default:
// Default to Revolut Pro
return s.GetOAuthConfig(RevolutAccountTypePro)
}
}
// GenerateAuthURL generates the OAuth2 authorization URL for both account types
func (s *RevolutOAuthService) GenerateAuthURL(accountType RevolutAccountType, state string, codeChallenge string) (string, error) {
config := s.GetOAuthConfig(accountType)
// For Revolut Pro, use the select-user-type endpoint
if accountType == RevolutAccountTypePro {
params := url.Values{}
params.Set("client_id", config.ClientID)
params.Set("redirect_uri", s.cfg.RevolutWebhookURL)
params.Set("response_type", "code")
params.Set("scope", "checkout_extension")
params.Set("code_challenge_method", "S256")
params.Set("code_challenge", codeChallenge)
params.Set("response_mode", "query")
params.Set("state", state)
params.Set("integration_type", "CUSTOM_PLUGIN")
params.Set("rwa_auth_type", "auth")
authURL := fmt.Sprintf("%s/s/select-user-type?%s", config.AuthBaseURL, params.Encode())
return authURL, nil
}
// For Revolut Business, use direct SSO
params := url.Values{}
params.Set("client_id", config.ClientID)
params.Set("redirect_uri", s.cfg.RevolutWebhookURL)
params.Set("response_type", "code")
params.Set("code_challenge_method", "S256")
params.Set("code_challenge", codeChallenge)
params.Set("response_mode", "query")
params.Set("prompt", "select_account")
params.Set("state", state)
authURL := fmt.Sprintf("%s/signin?%s", config.AuthBaseURL, params.Encode())
return authURL, nil
}
// GenerateCodeVerifier generates a PKCE code verifier
func GenerateCodeVerifier() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// GenerateCodeChallenge generates a PKCE code challenge from verifier
func GenerateCodeChallenge(verifier string) string {
h := sha256.New()
h.Write([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// ExchangeCodeForToken exchanges authorization code for access token
func (s *RevolutOAuthService) ExchangeCodeForToken(accountType RevolutAccountType, code, codeVerifier string) (*RevolutOAuthToken, error) {
config := s.GetOAuthConfig(accountType)
data := url.Values{}
data.Set("client_id", config.ClientID)
data.Set("code", code)
data.Set("code_verifier", codeVerifier)
data.Set("grant_type", "authorization_code")
data.Set("redirect_uri", s.cfg.RevolutWebhookURL)
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute token request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token API error: status %d, response: %s", resp.StatusCode, string(body))
}
var token RevolutOAuthToken
if err := json.Unmarshal(body, &token); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
// Store account type with token for later use
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
return &token, nil
}
// RefreshAccessToken refreshes the access token using refresh token
func (s *RevolutOAuthService) RefreshAccessToken(accountType RevolutAccountType, refreshToken string) (*RevolutOAuthToken, error) {
config := s.GetOAuthConfig(accountType)
data := url.Values{}
data.Set("client_id", config.ClientID)
data.Set("refresh_token", refreshToken)
data.Set("grant_type", "refresh_token")
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute refresh request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("refresh API error: status %d, response: %s", resp.StatusCode, string(body))
}
var token RevolutOAuthToken
if err := json.Unmarshal(body, &token); err != nil {
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
}
// Store account type with token
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
return &token, nil
}
// StoreOAuthToken stores the OAuth token with account type information
func (s *RevolutOAuthService) StoreOAuthToken(accountType RevolutAccountType, token *RevolutOAuthToken) error {
// Store token in database or secure storage
// You would typically store:
// - access_token
// - refresh_token
// - expires_at (calculated from expires_in)
// - token_type
// - scope (includes account_type)
// - account_type
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
// TODO: Implement database storage with account type
fmt.Printf("Storing OAuth token for %s: expires at %v\n", accountType, expiresAt)
return nil
}
// GetStoredOAuthToken retrieves stored OAuth token and account type
func (s *RevolutOAuthService) GetStoredOAuthToken() (*RevolutOAuthToken, RevolutAccountType, error) {
// TODO: Implement database retrieval
return nil, "", fmt.Errorf("not implemented")
}
// CreatePaymentWithOAuth creates a payment using OAuth token instead of API key
func (s *RevolutOAuthService) CreatePaymentWithOAuth(order *models.EshopOrder) (*PaymentResult, error) {
// Get stored OAuth token and account type
token, accountType, err := s.GetStoredOAuthToken()
if err != nil {
return nil, fmt.Errorf("failed to get OAuth token: %w", err)
}
// Check if token needs refresh
if token.ExpiresIn > 0 && token.RefreshToken != "" {
// Token might be expired, refresh it
newToken, err := s.RefreshAccessToken(accountType, token.RefreshToken)
if err == nil {
token = newToken
s.StoreOAuthToken(accountType, token)
}
}
// Create order using OAuth token
return s.createOrder(order, token.AccessToken, accountType)
}
// createOrder creates a Revolut order using OAuth access token
func (s *RevolutOAuthService) createOrder(order *models.EshopOrder, accessToken string, accountType RevolutAccountType) (*PaymentResult, error) {
config := s.GetOAuthConfig(accountType)
// Create order request
orderReq := map[string]interface{}{
"amount": order.TotalAmountCents,
"currency": order.Currency,
"description": fmt.Sprintf("Order %s", order.OrderNumber),
"merchant_order_id": fmt.Sprintf("%d", order.ID),
}
orderJSON, err := json.Marshal(orderReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal order request: %w", err)
}
req, err := http.NewRequest("POST", config.APIBaseURL+"/orders", bytes.NewBuffer(orderJSON))
if err != nil {
return nil, fmt.Errorf("failed to create order request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute order request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read order response: %w", err)
}
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("order API error: status %d, response: %s", resp.StatusCode, string(body))
}
var orderResp map[string]interface{}
if err := json.Unmarshal(body, &orderResp); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
orderID, ok := orderResp["id"].(string)
if !ok || orderID == "" {
return nil, fmt.Errorf("order response missing id")
}
return &PaymentResult{
RedirectURL: fmt.Sprintf("%s/checkout/%s", config.APIBaseURL, orderID),
ProviderPaymentID: orderID,
RawPayloadJSON: string(body),
}, nil
}
+282
View File
@@ -0,0 +1,282 @@
package eshop
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/httpclient"
)
// RevolutService handles Revolut payment integration
type RevolutService struct {
cfg *config.Config
client *http.Client
apiBase string
}
// Revolut API structures
type RevolutOrderRequest struct {
Amount int64 `json:"amount"` // Amount in minor currency units (cents)
Currency string `json:"currency"` // 3-letter currency code (EUR, CZK, etc.)
Description string `json:"description"` // Order description
MerchantOrderID string `json:"merchant_order_id,omitempty"` // Your internal order ID
Customer *RevolutCustomer `json:"customer,omitempty"`
}
type RevolutCustomer struct {
Email string `json:"email,omitempty"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Phone string `json:"phone,omitempty"`
}
type RevolutOrderResponse struct {
ID string `json:"id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"` // PENDING, COMPLETED, FAILED
CheckoutURL string `json:"checkout_url,omitempty"`
MerchantOrderID string `json:"merchant_order_id,omitempty"`
CreatedAt string `json:"created_at"`
}
type RevolutWebhookPayload struct {
Type string `json:"type"` // ORDER_COMPLETED, ORDER_CANCELLED, etc.
OrderID string `json:"order_id"`
Order RevolutOrderResponse `json:"order"`
Timestamp string `json:"timestamp"`
}
func NewRevolutService(cfg *config.Config) *RevolutService {
base := "https://sandbox-merchant.revolut.com/api"
if strings.ToLower(strings.TrimSpace(cfg.RevolutEnvironment)) == "production" {
base = "https://merchant.revolut.com/api"
}
return &RevolutService{
cfg: cfg,
client: httpclient.DefaultClient(),
apiBase: base,
}
}
// CreatePayment initializes a Revolut payment for the given order
func (s *RevolutService) CreatePayment(order *models.EshopOrder) (*PaymentResult, error) {
if !s.cfg.RevolutEnabled {
return nil, fmt.Errorf("Revolut payment provider is disabled")
}
apiKey := strings.TrimSpace(s.cfg.RevolutAPIKey)
if apiKey == "" {
return nil, fmt.Errorf("Revolut API key not configured")
}
// Parse billing address to get customer information
var billingAddress map[string]interface{}
if order.BillingAddressJSON != "" {
if err := json.Unmarshal([]byte(order.BillingAddressJSON), &billingAddress); err != nil {
return nil, fmt.Errorf("failed to parse billing address: %w", err)
}
}
// Create order request
orderReq := RevolutOrderRequest{
Amount: order.TotalAmountCents,
Currency: order.Currency,
Description: fmt.Sprintf("Objednávka %s", order.OrderNumber),
MerchantOrderID: order.OrderNumber,
Customer: &RevolutCustomer{
Email: order.Email,
FirstName: getStringFromMap(billingAddress, "first_name"),
LastName: getStringFromMap(billingAddress, "last_name"),
Phone: getStringFromMap(billingAddress, "phone"),
},
}
// Convert to JSON
jsonData, err := json.Marshal(orderReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal Revolut order request: %w", err)
}
// Create HTTP request
url := fmt.Sprintf("%s/1.0/orders", s.apiBase)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create Revolut order request: %w", err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
// Execute request
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute Revolut order request: %w", err)
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Revolut order response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("Revolut API error: status %d, response: %s", resp.StatusCode, string(body))
}
// Parse response
var orderResp RevolutOrderResponse
if err := json.Unmarshal(body, &orderResp); err != nil {
return nil, fmt.Errorf("failed to parse Revolut order response: %w", err)
}
// Store raw payload for debugging
rawPayload := string(body)
// Return payment result
return &PaymentResult{
RedirectURL: orderResp.CheckoutURL,
ProviderPaymentID: orderResp.ID,
RawPayloadJSON: rawPayload,
}, nil
}
// VerifyWebhook verifies and parses incoming Revolut webhooks
func (s *RevolutService) VerifyWebhook(payload []byte, signature string) (bool, error) {
webhookSecret := strings.TrimSpace(s.cfg.RevolutWebhookSecret)
if webhookSecret == "" {
return false, fmt.Errorf("Revolut webhook secret not configured")
}
// TODO: Implement webhook signature verification
// Revolut uses HMAC-SHA256 for webhook signatures
// For now, we'll accept all webhooks in sandbox mode
if strings.ToLower(strings.TrimSpace(s.cfg.RevolutEnvironment)) == "sandbox" {
return true, nil
}
// In production, verify the signature here
// This would involve computing HMAC-SHA256 of the payload with the webhook secret
// and comparing it with the provided signature
return true, nil
}
// ParseWebhook parses a Revolut webhook payload
func (s *RevolutService) ParseWebhook(payload []byte) (*RevolutWebhookPayload, error) {
var webhook RevolutWebhookPayload
if err := json.Unmarshal(payload, &webhook); err != nil {
return nil, fmt.Errorf("failed to parse Revolut webhook: %w", err)
}
return &webhook, nil
}
// GetOrderStatus retrieves the current status of a Revolut order
func (s *RevolutService) GetOrderStatus(revolutOrderID string) (*RevolutOrderResponse, error) {
if !s.cfg.RevolutEnabled {
return nil, fmt.Errorf("Revolut payment provider is disabled")
}
apiKey := strings.TrimSpace(s.cfg.RevolutAPIKey)
if apiKey == "" {
return nil, fmt.Errorf("Revolut API key not configured")
}
url := fmt.Sprintf("%s/1.0/orders/%s", s.apiBase, revolutOrderID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create Revolut status request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute Revolut status request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Revolut status response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Revolut API error: status %d, response: %s", resp.StatusCode, string(body))
}
var orderResp RevolutOrderResponse
if err := json.Unmarshal(body, &orderResp); err != nil {
return nil, fmt.Errorf("failed to parse Revolut status response: %w", err)
}
return &orderResp, nil
}
// RefundOrder processes a refund for a Revolut order
func (s *RevolutService) RefundOrder(revolutOrderID string, amount int64, reason string) error {
if !s.cfg.RevolutEnabled {
return fmt.Errorf("Revolut payment provider is disabled")
}
apiKey := strings.TrimSpace(s.cfg.RevolutAPIKey)
if apiKey == "" {
return fmt.Errorf("Revolut API key not configured")
}
// Refund request structure
refundReq := map[string]interface{}{
"amount": amount,
"reason": reason,
}
jsonData, err := json.Marshal(refundReq)
if err != nil {
return fmt.Errorf("failed to marshal Revolut refund request: %w", err)
}
url := fmt.Sprintf("%s/1.0/orders/%s/refund", s.apiBase, revolutOrderID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create Revolut refund request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("failed to execute Revolut refund request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Revolut refund API error: status %d, response: %s", resp.StatusCode, string(body))
}
return nil
}
// getStringFromMap safely extracts a string value from a map, returning empty string if not found or not a string
func getStringFromMap(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if val, ok := m[key]; ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
+158
View File
@@ -0,0 +1,158 @@
package eshop
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/httpclient"
)
// PaymentResult represents a generic result from a payment provider
type PaymentResult struct {
RedirectURL string
ProviderPaymentID string
RawPayloadJSON string
}
// StripeService handles Stripe payment integration
type StripeService struct {
cfg *config.Config
client *http.Client
}
// NewStripeService creates a new Stripe service instance
func NewStripeService(cfg *config.Config) *StripeService {
return &StripeService{
cfg: cfg,
client: httpclient.DefaultClient(),
}
}
// CreatePayment creates a Stripe PaymentIntent for the given order
func (s *StripeService) CreatePayment(order *models.EshopOrder) (*PaymentResult, error) {
if !s.cfg.StripeEnabled {
return nil, fmt.Errorf("Stripe payment provider is disabled")
}
secretKey := strings.TrimSpace(s.cfg.StripeSecretKey)
if secretKey == "" {
return nil, fmt.Errorf("Stripe secret key not configured")
}
// Create PaymentIntent request
paymentIntentReq := map[string]interface{}{
"amount": order.TotalAmountCents,
"currency": strings.ToLower(order.Currency),
"metadata": map[string]string{
"order_id": fmt.Sprintf("%d", order.ID),
"order_number": order.OrderNumber,
},
"automatic_payment_methods": map[string]interface{}{
"enabled": true,
},
}
paymentJSON, err := json.Marshal(paymentIntentReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal Stripe PaymentIntent request: %w", err)
}
// Create PaymentIntent via Stripe API
req, err := http.NewRequest("POST", "https://api.stripe.com/v1/payment_intents", bytes.NewBuffer(paymentJSON))
if err != nil {
return nil, fmt.Errorf("failed to create Stripe PaymentIntent request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+secretKey)
// Convert JSON to form-encoded for Stripe API
formData := fmt.Sprintf("amount=%d&currency=%s&automatic_payment_methods[enabled]=true",
order.TotalAmountCents, strings.ToLower(order.Currency))
req.Body = io.NopCloser(strings.NewReader(formData))
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute Stripe PaymentIntent request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Stripe PaymentIntent response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Stripe API error: status %d, response: %s", resp.StatusCode, string(body))
}
var paymentIntent map[string]interface{}
if err := json.Unmarshal(body, &paymentIntent); err != nil {
return nil, fmt.Errorf("failed to parse Stripe PaymentIntent response: %w", err)
}
clientSecret, ok := paymentIntent["client_secret"].(string)
if !ok || clientSecret == "" {
return nil, fmt.Errorf("Stripe PaymentIntent response missing client_secret")
}
paymentID, ok := paymentIntent["id"].(string)
if !ok || paymentID == "" {
return nil, fmt.Errorf("Stripe PaymentIntent response missing id")
}
return &PaymentResult{
RedirectURL: "", // Stripe uses client_secret, not redirect
ProviderPaymentID: paymentID,
RawPayloadJSON: string(body),
}, nil
}
// GetClientSecret returns the client secret for a PaymentIntent (for frontend use)
func (s *StripeService) GetClientSecret(paymentIntentID string) (string, error) {
if s.cfg.StripeSecretKey == "" {
return "", fmt.Errorf("Stripe secret key not configured")
}
url := fmt.Sprintf("https://api.stripe.com/v1/payment_intents/%s", paymentIntentID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.SetBasicAuth(s.cfg.StripeSecretKey, "")
req.Header.Set("Stripe-Version", "2023-10-16")
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to make request to Stripe: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Stripe API error: %d - %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
clientSecret, ok := result["client_secret"].(string)
if !ok {
return "", fmt.Errorf("no client secret in response")
}
return clientSecret, nil
}