184 lines
6.0 KiB
Go
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
|
|
}
|