Files
jbergner bcfbef390f
release-tag / release-image (push) Failing after 2m44s
RC-7
2026-08-11 16:58:07 +02:00

184 lines
6.0 KiB
Go

package customer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type PayPalClient struct {
ClientID, Secret, BaseURL string
hc *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
func NewPayPalClient(clientID, secret, environment string) *PayPalClient {
base := "https://api-m.sandbox.paypal.com"
if strings.EqualFold(strings.TrimSpace(environment), "live") {
base = "https://api-m.paypal.com"
}
return &PayPalClient{ClientID: strings.TrimSpace(clientID), Secret: strings.TrimSpace(secret), BaseURL: base, hc: &http.Client{Timeout: 20 * time.Second}}
}
func (p *PayPalClient) Ready() bool { return p.ClientID != "" && p.Secret != "" }
func (p *PayPalClient) accessToken(ctx context.Context) (string, error) {
p.mu.Lock()
if p.token != "" && time.Until(p.tokenExp) > time.Minute {
v := p.token
p.mu.Unlock()
return v, nil
}
p.mu.Unlock()
if !p.Ready() {
return "", errors.New("PayPal client ID/secret not configured")
}
form := url.Values{"grant_type": {"client_credentials"}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+"/v1/oauth2/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.SetBasicAuth(p.ClientID, p.Secret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := p.hc.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("PayPal OAuth HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(b, &out); err != nil {
return "", err
}
if out.AccessToken == "" {
return "", errors.New("PayPal OAuth returned empty token")
}
p.mu.Lock()
p.token = out.AccessToken
p.tokenExp = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
p.mu.Unlock()
return out.AccessToken, nil
}
func (p *PayPalClient) call(ctx context.Context, method, path string, in, out any) error {
tok, err := p.accessToken(ctx)
if err != nil {
return err
}
var rd io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, p.BaseURL+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("PayPal API %s HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(b)))
}
if out != nil && len(bytes.TrimSpace(b)) > 0 {
return json.Unmarshal(b, out)
}
return nil
}
type PayPalOrderView struct {
ID string `json:"id"`
Status string `json:"status"`
Links []struct{ Href, Rel, Method string } `json:"links"`
PurchaseUnits []struct {
Amount struct {
CurrencyCode string `json:"currency_code"`
Value string `json:"value"`
} `json:"amount"`
Payments struct {
Captures []struct {
ID, Status string
Amount struct {
CurrencyCode string `json:"currency_code"`
Value string `json:"value"`
} `json:"amount"`
} `json:"captures"`
} `json:"payments"`
} `json:"purchase_units"`
}
func (p *PayPalClient) CreateOrder(ctx context.Context, reference, description, amount, currency, returnURL, cancelURL string) (PayPalOrderView, error) {
body := map[string]any{"intent": "CAPTURE", "purchase_units": []map[string]any{{"reference_id": reference, "description": description, "amount": map[string]string{"currency_code": currency, "value": amount}}}, "payment_source": map[string]any{"paypal": map[string]any{"experience_context": map[string]any{"shipping_preference": "NO_SHIPPING", "user_action": "PAY_NOW", "return_url": returnURL, "cancel_url": cancelURL}}}}
var out PayPalOrderView
err := p.call(ctx, http.MethodPost, "/v2/checkout/orders", body, &out)
return out, err
}
func (p *PayPalClient) CaptureOrder(ctx context.Context, id string) (PayPalOrderView, error) {
var out PayPalOrderView
err := p.call(ctx, http.MethodPost, "/v2/checkout/orders/"+url.PathEscape(id)+"/capture", map[string]any{}, &out)
return out, err
}
func (p *PayPalClient) GetOrder(ctx context.Context, id string) (PayPalOrderView, error) {
var out PayPalOrderView
err := p.call(ctx, http.MethodGet, "/v2/checkout/orders/"+url.PathEscape(id), nil, &out)
return out, err
}
func ApprovalURL(o PayPalOrderView) string {
for _, l := range o.Links {
if l.Rel == "payer-action" || l.Rel == "approve" {
return l.Href
}
}
return ""
}
func CaptureID(o PayPalOrderView) string {
for _, u := range o.PurchaseUnits {
for _, c := range u.Payments.Captures {
if strings.EqualFold(c.Status, "COMPLETED") {
return c.ID
}
}
}
return ""
}
func (p *PayPalClient) VerifyWebhook(ctx context.Context, webhookID string, h http.Header, event json.RawMessage) (bool, error) {
if strings.TrimSpace(webhookID) == "" {
return false, errors.New("PAYPAL_WEBHOOK_ID not configured")
}
var ev any
if err := json.Unmarshal(event, &ev); err != nil {
return false, err
}
body := map[string]any{"auth_algo": h.Get("PAYPAL-AUTH-ALGO"), "cert_url": h.Get("PAYPAL-CERT-URL"), "transmission_id": h.Get("PAYPAL-TRANSMISSION-ID"), "transmission_sig": h.Get("PAYPAL-TRANSMISSION-SIG"), "transmission_time": h.Get("PAYPAL-TRANSMISSION-TIME"), "webhook_id": webhookID, "webhook_event": ev}
var out struct {
VerificationStatus string `json:"verification_status"`
}
if err := p.call(ctx, http.MethodPost, "/v1/notifications/verify-webhook-signature", body, &out); err != nil {
return false, err
}
return strings.EqualFold(out.VerificationStatus, "SUCCESS"), nil
}