235 lines
5.6 KiB
Go
235 lines
5.6 KiB
Go
|
|
package billing
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
)
|
||
|
|
|
||
|
|
const lemonBaseURL = "https://api.lemonsqueezy.com/v1"
|
||
|
|
|
||
|
|
type LemonClient struct {
|
||
|
|
apiKey string
|
||
|
|
storeID string
|
||
|
|
webhookSecret string
|
||
|
|
httpClient *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewLemonClient() *LemonClient {
|
||
|
|
return &LemonClient{
|
||
|
|
apiKey: os.Getenv("LEMON_API_KEY"),
|
||
|
|
storeID: os.Getenv("LEMON_STORE_ID"),
|
||
|
|
webhookSecret: os.Getenv("LEMON_WEBHOOK_SECRET"),
|
||
|
|
httpClient: &http.Client{},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *LemonClient) IsConfigured() bool {
|
||
|
|
return c.apiKey != "" && c.storeID != ""
|
||
|
|
}
|
||
|
|
|
||
|
|
type CheckoutRequest struct {
|
||
|
|
StoreID string `json:"store_id"`
|
||
|
|
VariantID string `json:"variant_id"`
|
||
|
|
CustomPrice *int `json:"custom_price,omitempty"`
|
||
|
|
CheckoutOptions *CheckoutOptions `json:"checkout_options,omitempty"`
|
||
|
|
CheckoutData *CheckoutData `json:"checkout_data,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CheckoutOptions struct {
|
||
|
|
Embed bool `json:"embed,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CheckoutData struct {
|
||
|
|
Email string `json:"email,omitempty"`
|
||
|
|
Name string `json:"name,omitempty"`
|
||
|
|
Custom map[string]string `json:"custom,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CheckoutResponse struct {
|
||
|
|
Data struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Attributes struct {
|
||
|
|
URL string `json:"url"`
|
||
|
|
} `json:"attributes"`
|
||
|
|
} `json:"data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *LemonClient) CreateCheckout(ctx context.Context, req *CheckoutRequest) (*CheckoutResponse, error) {
|
||
|
|
if req.StoreID == "" {
|
||
|
|
req.StoreID = c.storeID
|
||
|
|
}
|
||
|
|
|
||
|
|
payload := map[string]any{
|
||
|
|
"data": map[string]any{
|
||
|
|
"type": "checkouts",
|
||
|
|
"attributes": req,
|
||
|
|
"relationships": map[string]any{
|
||
|
|
"store": map[string]any{
|
||
|
|
"data": map[string]any{"type": "stores", "id": req.StoreID},
|
||
|
|
},
|
||
|
|
"variant": map[string]any{
|
||
|
|
"data": map[string]any{"type": "variants", "id": req.VariantID},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
body, err := json.Marshal(payload)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", lemonBaseURL+"/checkouts", bytes.NewReader(body))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||
|
|
httpReq.Header.Set("Content-Type", "application/vnd.api+json")
|
||
|
|
httpReq.Header.Set("Accept", "application/vnd.api+json")
|
||
|
|
|
||
|
|
resp, err := c.httpClient.Do(httpReq)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
respBody, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||
|
|
return nil, fmt.Errorf("checkout creation failed (status %d): %s", resp.StatusCode, string(respBody))
|
||
|
|
}
|
||
|
|
|
||
|
|
var checkoutResp CheckoutResponse
|
||
|
|
if err := json.Unmarshal(respBody, &checkoutResp); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return &checkoutResp, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *LemonClient) CreateSubscriptionCheckout(ctx context.Context, variantID, email, name string, custom map[string]string) (string, error) {
|
||
|
|
req := &CheckoutRequest{
|
||
|
|
VariantID: variantID,
|
||
|
|
CheckoutData: &CheckoutData{
|
||
|
|
Email: email,
|
||
|
|
Name: name,
|
||
|
|
Custom: custom,
|
||
|
|
},
|
||
|
|
CheckoutOptions: &CheckoutOptions{Embed: true},
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := c.CreateCheckout(ctx, req)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
return resp.Data.Attributes.URL, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *LemonClient) CreateDonationCheckout(ctx context.Context, variantID string, amountCents int, email, name, message string, custom map[string]string) (string, error) {
|
||
|
|
if custom == nil {
|
||
|
|
custom = make(map[string]string)
|
||
|
|
}
|
||
|
|
if message != "" {
|
||
|
|
custom["message"] = message
|
||
|
|
}
|
||
|
|
|
||
|
|
req := &CheckoutRequest{
|
||
|
|
VariantID: variantID,
|
||
|
|
CustomPrice: &amountCents,
|
||
|
|
CheckoutData: &CheckoutData{
|
||
|
|
Email: email,
|
||
|
|
Name: name,
|
||
|
|
Custom: custom,
|
||
|
|
},
|
||
|
|
CheckoutOptions: &CheckoutOptions{Embed: true},
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := c.CreateCheckout(ctx, req)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
return resp.Data.Attributes.URL, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *LemonClient) VerifyWebhook(payload []byte, signature string) bool {
|
||
|
|
if c.webhookSecret == "" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
mac := hmac.New(sha256.New, []byte(c.webhookSecret))
|
||
|
|
mac.Write(payload)
|
||
|
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||
|
|
|
||
|
|
return hmac.Equal([]byte(signature), []byte(expectedSig))
|
||
|
|
}
|
||
|
|
|
||
|
|
type WebhookEvent struct {
|
||
|
|
Meta struct {
|
||
|
|
EventName string `json:"event_name"`
|
||
|
|
CustomData map[string]string `json:"custom_data"`
|
||
|
|
} `json:"meta"`
|
||
|
|
Data json.RawMessage `json:"data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type SubscriptionData struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Attributes struct {
|
||
|
|
CustomerID int `json:"customer_id"`
|
||
|
|
VariantName string `json:"variant_name"`
|
||
|
|
UserName string `json:"user_name"`
|
||
|
|
UserEmail string `json:"user_email"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
RenewsAt string `json:"renews_at"`
|
||
|
|
FirstSubscriptionItem struct {
|
||
|
|
Price int `json:"price"`
|
||
|
|
} `json:"first_subscription_item"`
|
||
|
|
} `json:"attributes"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type OrderData struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Attributes struct {
|
||
|
|
UserName string `json:"user_name"`
|
||
|
|
UserEmail string `json:"user_email"`
|
||
|
|
TotalUsd int `json:"total_usd"`
|
||
|
|
} `json:"attributes"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *LemonClient) ParseWebhookEvent(payload []byte) (*WebhookEvent, error) {
|
||
|
|
var event WebhookEvent
|
||
|
|
if err := json.Unmarshal(payload, &event); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &event, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *WebhookEvent) GetSubscriptionData() (*SubscriptionData, error) {
|
||
|
|
var data SubscriptionData
|
||
|
|
if err := json.Unmarshal(e.Data, &data); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &data, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *WebhookEvent) GetOrderData() (*OrderData, error) {
|
||
|
|
var data OrderData
|
||
|
|
if err := json.Unmarshal(e.Data, &data); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &data, nil
|
||
|
|
}
|