writekit/internal/billing/lemon.go
2026-01-09 00:16:46 +02:00

234 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
}