mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
hot fix #1
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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¤cy=%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
|
||||
}
|
||||
@@ -65,6 +65,15 @@ func ProcessFACRLogo(src string) (string, error) {
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("empty url")
|
||||
}
|
||||
// In manual club data mode we avoid any remote FACR/fotbal.cz processing and
|
||||
// return the original URL unchanged so that LogoAPI/manual overrides can be
|
||||
// applied on the frontend without additional HTTP calls.
|
||||
if config.AppConfig != nil {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.AppConfig.ClubDataMode))
|
||||
if mode == "manual" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
// Feature flag: allow disabling background removal entirely via .env
|
||||
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
|
||||
// Simply return the original URL (no processing)
|
||||
|
||||
@@ -20,6 +20,7 @@ type logoAPIResponse struct {
|
||||
LogoURLSVG string `json:"logo_url_svg"`
|
||||
LogoURLPNG string `json:"logo_url_png"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
ClubName string `json:"club_name"`
|
||||
}
|
||||
|
||||
func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
@@ -137,11 +138,11 @@ func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
if db != nil {
|
||||
if err := db.Where("file_path = ?", dest).First(&existing).Error; err != nil {
|
||||
uf := models.UploadedFile{
|
||||
Filename: "club-logo" + ext,
|
||||
FilePath: dest,
|
||||
FileURL: publicURL,
|
||||
MimeType: mime,
|
||||
FileSize: 0,
|
||||
Filename: "club-logo" + ext,
|
||||
FilePath: dest,
|
||||
FileURL: publicURL,
|
||||
MimeType: mime,
|
||||
FileSize: 0,
|
||||
UploadedByID: nil,
|
||||
}
|
||||
if fi != nil {
|
||||
@@ -153,3 +154,47 @@ func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
|
||||
return publicURL, nil
|
||||
}
|
||||
|
||||
type ClubLogoAndName struct {
|
||||
LogoURL string
|
||||
ClubName string
|
||||
}
|
||||
|
||||
func CacheClubLogoAndName(db *gorm.DB, clubID string) (*ClubLogoAndName, error) {
|
||||
cid := strings.TrimSpace(clubID)
|
||||
if cid == "" {
|
||||
return nil, fmt.Errorf("empty club id")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
req, err := http.NewRequest("GET", "https://logoapi.sportcreative.eu/logos/"+cid+"/json", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "fotbal-club/logo-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("logoapi status %d", resp.StatusCode)
|
||||
}
|
||||
var api logoAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the logo using existing function
|
||||
logoURL, err := CacheClubLogo(db, clubID)
|
||||
if err != nil {
|
||||
// Even if logo caching fails, we still return the club name if available
|
||||
logoURL = ""
|
||||
}
|
||||
|
||||
return &ClubLogoAndName{
|
||||
LogoURL: logoURL,
|
||||
ClubName: strings.TrimSpace(api.ClubName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -148,12 +148,22 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
|
||||
currentDay := strings.ToLower(now.Weekday().String()[:3])
|
||||
currentHour := now.Hour()
|
||||
|
||||
// Check if it's the right day and hour
|
||||
if currentDay != targetDay || currentHour != targetHour {
|
||||
// Check if it's the right day and hour (with minute precision to prevent multiple sends)
|
||||
currentMinute := now.Minute()
|
||||
if currentDay != targetDay || currentHour != targetHour || currentMinute > 5 {
|
||||
// Only run in the first 5 minutes of the target hour to avoid repeats
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already sent today
|
||||
// Check if already sent today (using database for persistence)
|
||||
var todaySent models.NewsletterSentLog
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
if err := na.db.Where("newsletter_type = ? AND sent_at >= ?", "weekly", todayStart).First(&todaySent).Error; err == nil {
|
||||
log.Printf("[newsletter-automation] Weekly digest already sent today at %s", todaySent.SentAt.Format("15:04:05"))
|
||||
return
|
||||
}
|
||||
|
||||
// Also check in-memory as backup
|
||||
if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
)
|
||||
|
||||
type PacketaService struct {
|
||||
ApiPassword string
|
||||
ApiUrl string
|
||||
EshopName string
|
||||
}
|
||||
|
||||
func NewPacketaService(cfg *config.Config) *PacketaService {
|
||||
// Defaults
|
||||
apiUrl := "https://www.zasilkovna.cz/api/rest"
|
||||
eshopName := "MyClubEshop"
|
||||
|
||||
if cfg.PacketaEshopName != "" {
|
||||
eshopName = cfg.PacketaEshopName
|
||||
}
|
||||
|
||||
return &PacketaService{
|
||||
ApiPassword: cfg.PacketaAPIPassword,
|
||||
ApiUrl: apiUrl,
|
||||
EshopName: eshopName,
|
||||
}
|
||||
}
|
||||
|
||||
// XML Structures
|
||||
type CreatePacketRequest struct {
|
||||
XMLName xml.Name `xml:"createPacket"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketAttributes PacketAttributes `xml:"packetAttributes"`
|
||||
}
|
||||
|
||||
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 CreatePacketResponse struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Status string `xml:"status"`
|
||||
Result PacketResult `xml:"result"`
|
||||
Fault *PacketFault `xml:"fault"`
|
||||
}
|
||||
|
||||
type PacketResult struct {
|
||||
PacketId string `xml:"packetId"`
|
||||
LabelUrl string `xml:"labelUrl"` // Not standard, usually requires separate call
|
||||
Barcode string `xml:"barcode"`
|
||||
}
|
||||
|
||||
type PacketFault struct {
|
||||
String string `xml:"string"`
|
||||
Detail string `xml:"detail"`
|
||||
}
|
||||
|
||||
// CreatePacket calls Packeta API to register the shipment
|
||||
func (s *PacketaService) CreatePacket(order *models.EshopOrder, addressId string) (string, error) {
|
||||
if s.ApiPassword == "" {
|
||||
return "", fmt.Errorf("Packeta API password not configured")
|
||||
}
|
||||
|
||||
req := CreatePacketRequest{
|
||||
ApiPassword: s.ApiPassword,
|
||||
PacketAttributes: PacketAttributes{
|
||||
Number: order.OrderNumber,
|
||||
Name: order.FirstName,
|
||||
Surname: order.LastName,
|
||||
Email: order.Email,
|
||||
Phone: "", // Add phone to order model if needed
|
||||
AddressId: addressId,
|
||||
Value: fmt.Sprintf("%.2f", float64(order.TotalAmountCents)/100.0),
|
||||
Currency: order.Currency,
|
||||
Weight: "1.0", // Default weight, should come from product sum
|
||||
Eshop: s.EshopName,
|
||||
},
|
||||
}
|
||||
|
||||
xmlData, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.ApiUrl, "text/xml", bytes.NewBuffer(xmlData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var xmlResp CreatePacketResponse
|
||||
if err := xml.Unmarshal(body, &xmlResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if xmlResp.Status != "ok" {
|
||||
msg := "unknown error"
|
||||
if xmlResp.Fault != nil {
|
||||
msg = xmlResp.Fault.String + ": " + xmlResp.Fault.Detail
|
||||
}
|
||||
return "", fmt.Errorf("packeta error: %s", msg)
|
||||
}
|
||||
|
||||
return xmlResp.Result.PacketId, nil
|
||||
}
|
||||
|
||||
// Helper struct for packet status
|
||||
type PacketStatusRequest struct {
|
||||
XMLName xml.Name `xml:"packetStatus"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketId string `xml:"packetId"`
|
||||
}
|
||||
|
||||
type PacketStatusResponse struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Status string `xml:"status"`
|
||||
Result PacketStatusResult `xml:"result"`
|
||||
}
|
||||
|
||||
type PacketStatusResult struct {
|
||||
StatusCode string `xml:"statusCode"`
|
||||
StatusText string `xml:"statusText"`
|
||||
}
|
||||
|
||||
func (s *PacketaService) GetPacketLabel(packetId string) ([]byte, error) {
|
||||
if s.ApiPassword == "" {
|
||||
return nil, fmt.Errorf("Packeta API password not configured")
|
||||
}
|
||||
|
||||
type PacketLabelPdfRequest struct {
|
||||
XMLName xml.Name `xml:"packetLabelPdf"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketId string `xml:"packetId"`
|
||||
Format string `xml:"format"`
|
||||
Offset int `xml:"offset"`
|
||||
}
|
||||
|
||||
req := PacketLabelPdfRequest{
|
||||
ApiPassword: s.ApiPassword,
|
||||
PacketId: packetId,
|
||||
Format: "A6 on A4",
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
xmlData, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.ApiUrl, "text/xml", bytes.NewBuffer(xmlData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("label request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (s *PacketaService) GetPacketStatus(packetId string) (string, error) {
|
||||
req := PacketStatusRequest{
|
||||
ApiPassword: s.ApiPassword,
|
||||
PacketId: packetId,
|
||||
}
|
||||
|
||||
xmlData, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.ApiUrl, "text/xml", bytes.NewBuffer(xmlData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var xmlResp PacketStatusResponse
|
||||
if err := xml.Unmarshal(body, &xmlResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if xmlResp.Status != "ok" {
|
||||
return "", fmt.Errorf("status check failed")
|
||||
}
|
||||
|
||||
return xmlResp.Result.StatusText, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPacketaService_GetPacketStatus_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read request body: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body, []byte("<packetStatus>")) {
|
||||
t.Fatalf("expected packetStatus XML request, got: %s", string(body))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
_, _ = w.Write([]byte(`
|
||||
<response>
|
||||
<status>ok</status>
|
||||
<result>
|
||||
<statusCode>DELIVERED</statusCode>
|
||||
<statusText>DELIVERED</statusText>
|
||||
</result>
|
||||
</response>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &PacketaService{
|
||||
ApiPassword: "test-password",
|
||||
ApiUrl: server.URL,
|
||||
EshopName: "TestEshop",
|
||||
}
|
||||
|
||||
status, err := service.GetPacketStatus("12345")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPacketStatus returned error: %v", err)
|
||||
}
|
||||
|
||||
if status != "DELIVERED" {
|
||||
t.Fatalf("expected status 'DELIVERED', got %q", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WeatherService struct {
|
||||
db *gorm.DB
|
||||
apiKey string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type WeatherResponse struct {
|
||||
Location struct {
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
TzID string `json:"tz_id"`
|
||||
LocalTime string `json:"localtime"`
|
||||
} `json:"location"`
|
||||
Current struct {
|
||||
LastUpdated string `json:"last_updated"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
UV float64 `json:"uv"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
} `json:"current"`
|
||||
Forecast struct {
|
||||
ForecastDay []struct {
|
||||
Date string `json:"date"`
|
||||
DateEpoch int64 `json:"date_epoch"`
|
||||
Day struct {
|
||||
MaxTempC float64 `json:"maxtemp_c"`
|
||||
MaxTempF float64 `json:"maxtemp_f"`
|
||||
MinTempC float64 `json:"mintemp_c"`
|
||||
MinTempF float64 `json:"mintemp_f"`
|
||||
AvgTempC float64 `json:"avgtemp_c"`
|
||||
AvgTempF float64 `json:"avgtemp_f"`
|
||||
MaxWindMph float64 `json:"maxwind_mph"`
|
||||
MaxWindKph float64 `json:"maxwind_kph"`
|
||||
TotalPrecipMm float64 `json:"totalprecip_mm"`
|
||||
TotalPrecipIn float64 `json:"totalprecip_in"`
|
||||
TotalSnowCm float64 `json:"totalsnow_cm"`
|
||||
AvgVisKm float64 `json:"avgvis_km"`
|
||||
AvgVisMiles float64 `json:"avgvis_miles"`
|
||||
AvgHumidity float64 `json:"avghumidity"`
|
||||
DailyWillItRain int `json:"daily_will_it_rain"`
|
||||
DailyChanceOfRain int `json:"daily_chance_of_rain"`
|
||||
DailyWillItSnow int `json:"daily_will_it_snow"`
|
||||
DailyChanceOfSnow int `json:"daily_chance_of_snow"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
UV float64 `json:"uv"`
|
||||
} `json:"day"`
|
||||
Astro struct {
|
||||
Sunrise string `json:"sunrise"`
|
||||
Sunset string `json:"sunset"`
|
||||
Moonrise string `json:"moonrise"`
|
||||
Moonset string `json:"moonset"`
|
||||
MoonPhase string `json:"moon_phase"`
|
||||
MoonIllumination float64 `json:"moon_illumination"`
|
||||
IsMoonUp int `json:"is_moon_up"`
|
||||
IsSunUp int `json:"is_sun_up"`
|
||||
} `json:"astro"`
|
||||
Hour []struct {
|
||||
TimeEpoch int64 `json:"time_epoch"`
|
||||
Time string `json:"time"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
WindChillC float64 `json:"windchill_c"`
|
||||
WindChillF float64 `json:"windchill_f"`
|
||||
HeatIndexC float64 `json:"heatindex_c"`
|
||||
HeatIndexF float64 `json:"heatindex_f"`
|
||||
DewPointC float64 `json:"dewpoint_c"`
|
||||
DewPointF float64 `json:"dewpoint_f"`
|
||||
WillItRain int `json:"will_it_rain"`
|
||||
ChanceOfRain int `json:"chance_of_rain"`
|
||||
WillItSnow int `json:"will_it_snow"`
|
||||
ChanceOfSnow int `json:"chance_of_snow"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
UV float64 `json:"uv"`
|
||||
} `json:"hour"`
|
||||
} `json:"forecastday"`
|
||||
} `json:"forecast"`
|
||||
Alerts []struct {
|
||||
Headline string `json:"headline"`
|
||||
MsgType string `json:"msgtype"`
|
||||
Severity string `json:"severity"`
|
||||
Urgency string `json:"urgency"`
|
||||
Areas string `json:"areas"`
|
||||
Category string `json:"category"`
|
||||
Certainty string `json:"certainty"`
|
||||
Event string `json:"event"`
|
||||
Note string `json:"note"`
|
||||
Effective string `json:"effective"`
|
||||
Expires string `json:"expires"`
|
||||
Desc string `json:"desc"`
|
||||
Instruction string `json:"instruction"`
|
||||
} `json:"alerts"`
|
||||
}
|
||||
|
||||
func NewWeatherService(db *gorm.DB) *WeatherService {
|
||||
apiKey := os.Getenv("WEATHER_API_KEY")
|
||||
baseURL := os.Getenv("WEATHER_API_BASE_URL")
|
||||
|
||||
if apiKey == "" {
|
||||
apiKey = "20dfd9a556ec43888dc103523250904" // fallback
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.weatherapi.com/v1"
|
||||
}
|
||||
|
||||
return &WeatherService{
|
||||
db: db,
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherByLocation(location string) (*WeatherResponse, error) {
|
||||
if location == "" {
|
||||
// Try to get club location from settings
|
||||
location = ws.getClubLocation()
|
||||
if location == "" {
|
||||
return nil, fmt.Errorf("no location specified and no club location found")
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/forecast.json?key=%s&q=%s&days=3&aqi=no&alerts=no",
|
||||
ws.baseURL, ws.apiKey, location)
|
||||
|
||||
resp, err := ws.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("weather API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var weatherResp WeatherResponse
|
||||
if err := json.Unmarshal(body, &weatherResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse weather response: %w", err)
|
||||
}
|
||||
|
||||
return &weatherResp, nil
|
||||
}
|
||||
|
||||
func (ws *WeatherService) getClubLocation() string {
|
||||
var settings models.Settings
|
||||
if err := ws.db.First(&settings).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If coordinates are available, use them for most accurate weather
|
||||
if settings.LocationLatitude != 0 && settings.LocationLongitude != 0 {
|
||||
return fmt.Sprintf("%.6f,%.6f", settings.LocationLatitude, settings.LocationLongitude)
|
||||
}
|
||||
|
||||
// Try different location fields in order of preference
|
||||
if settings.ContactCity != "" {
|
||||
location := settings.ContactCity
|
||||
if settings.ContactCountry != "" &&
|
||||
settings.ContactCountry != "Czech Republic" &&
|
||||
settings.ContactCountry != "Česká republika" &&
|
||||
settings.ContactCountry != "Česko" {
|
||||
location += "," + settings.ContactCountry
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
// Fallback to a default Czech city if club is in Czech Republic
|
||||
if settings.ContactCountry == "Czech Republic" ||
|
||||
settings.ContactCountry == "Česká republika" ||
|
||||
settings.ContactCountry == "Česko" {
|
||||
return "Prague"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherForClub() (*WeatherResponse, error) {
|
||||
return ws.GetWeatherByLocation("")
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherForMatch(matchDateTime string, location string) (*WeatherResponse, error) {
|
||||
if location == "" {
|
||||
location = ws.getClubLocation()
|
||||
if location == "" {
|
||||
return nil, fmt.Errorf("no location specified and no club location found")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse match date to determine if we need hourly forecast
|
||||
matchTime, err := time.Parse("2006-01-02T15:04:05", matchDateTime)
|
||||
if err != nil {
|
||||
// Try alternative format
|
||||
matchTime, err = time.Parse("2006-01-02 15:04:05", matchDateTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid match date time format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If match is more than 7 days away, regular forecast won't be available
|
||||
now := time.Now()
|
||||
if matchTime.Sub(now) > 7*24*time.Hour {
|
||||
return nil, fmt.Errorf("match is too far in the future for weather forecast")
|
||||
}
|
||||
|
||||
// If match is in the past, no forecast needed
|
||||
if matchTime.Before(now) {
|
||||
return nil, fmt.Errorf("match is in the past")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/forecast.json?key=%s&q=%s&days=7&aqi=no&alerts=no",
|
||||
ws.baseURL, ws.apiKey, location)
|
||||
|
||||
resp, err := ws.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("weather API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var weatherResp WeatherResponse
|
||||
if err := json.Unmarshal(body, &weatherResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse weather response: %w", err)
|
||||
}
|
||||
|
||||
return &weatherResp, nil
|
||||
}
|
||||
|
||||
func (ws *WeatherService) FindClosestHourlyForecast(weather *WeatherResponse, matchTime time.Time) *struct {
|
||||
TimeEpoch int64 `json:"time_epoch"`
|
||||
Time string `json:"time"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
WindChillC float64 `json:"windchill_c"`
|
||||
WindChillF float64 `json:"windchill_f"`
|
||||
HeatIndexC float64 `json:"heatindex_c"`
|
||||
HeatIndexF float64 `json:"heatindex_f"`
|
||||
DewPointC float64 `json:"dewpoint_c"`
|
||||
DewPointF float64 `json:"dewpoint_f"`
|
||||
WillItRain int `json:"will_it_rain"`
|
||||
ChanceOfRain int `json:"chance_of_rain"`
|
||||
WillItSnow int `json:"will_it_snow"`
|
||||
ChanceOfSnow int `json:"chance_of_snow"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
UV float64 `json:"uv"`
|
||||
} {
|
||||
var closestHour *struct {
|
||||
TimeEpoch int64 `json:"time_epoch"`
|
||||
Time string `json:"time"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
WindChillC float64 `json:"windchill_c"`
|
||||
WindChillF float64 `json:"windchill_f"`
|
||||
HeatIndexC float64 `json:"heatindex_c"`
|
||||
HeatIndexF float64 `json:"heatindex_f"`
|
||||
DewPointC float64 `json:"dewpoint_c"`
|
||||
DewPointF float64 `json:"dewpoint_f"`
|
||||
WillItRain int `json:"will_it_rain"`
|
||||
ChanceOfRain int `json:"chance_of_rain"`
|
||||
WillItSnow int `json:"will_it_snow"`
|
||||
ChanceOfSnow int `json:"chance_of_snow"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
UV float64 `json:"uv"`
|
||||
}
|
||||
|
||||
minDiff := 24 * time.Hour // Start with a large difference
|
||||
|
||||
for _, day := range weather.Forecast.ForecastDay {
|
||||
for _, hour := range day.Hour {
|
||||
hourTime := time.Unix(hour.TimeEpoch, 0)
|
||||
diff := hourTime.Sub(matchTime)
|
||||
if diff >= 0 && diff < minDiff {
|
||||
minDiff = diff
|
||||
closestHour = &hour
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closestHour
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherIconURL(icon string) string {
|
||||
if icon == "" {
|
||||
return ""
|
||||
}
|
||||
// WeatherAPI provides relative URLs, make them absolute
|
||||
return "https:" + icon
|
||||
}
|
||||
Reference in New Issue
Block a user