clavitor/clavitor.ai/admin/paddle_checkout.go

336 lines
9.7 KiB
Go

// Paddle Checkout Integration
// Simplified - uses Paddle.js for client-side checkout
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// PaddleConfig holds our Paddle configuration
// In production these come from env vars
type PaddleConfig struct {
ClientToken string // For Paddle.js frontend
VendorID string // Your Paddle vendor ID
Environment string // "production" or "sandbox"
}
func getPaddleConfig() PaddleConfig {
return PaddleConfig{
// Client token is PUBLIC - safe to expose in JS
// This is different from the API key (which is secret)
ClientToken: os.Getenv("PADDLE_CLIENT_TOKEN"),
VendorID: os.Getenv("PADDLE_VENDOR_ID"),
Environment: "production",
}
}
// Price IDs - these come from your Paddle dashboard
// You create these when you set up products/prices
var PriceIDs = map[string]string{
"personal_yearly_usd": "pri_01knejm7ft2ska5r4qff2gm9r4",
"personal_yearly_eur": "pri_01knejm7kfhfknjyjq3je56r19",
"personal_yearly_gbp": "pri_01knejm7q2bkkszam05dtyrsnc",
"family_yearly_usd": "pri_01knejm7xs9kqt0vn61dx6q808",
"family_yearly_eur": "pri_01knejm811100z4xx2xxedwez6",
"family_yearly_gbp": "pri_01knejm85v2d1fjt998xypac2v",
"pro_yearly_usd": "pri_01knejm8djq4p63rmsxze7by58",
"pro_yearly_eur": "pri_01knejm8gjq5staaz9s7b4k8bp",
"pro_yearly_gbp": "pri_01knejm8kk3qwtk32kry5c607g",
"team10_yearly_usd": "pri_01knejm8twprj5ca4zem1g4g56",
"team25_yearly_usd": "pri_01knejm91d5xppcfw4w4w2tf7x",
"team100_yearly_usd": "pri_01knejm98a7bxvcpgtqm9aa81b",
"team250_yearly_usd": "pri_01knejm9fevyr7bse0hm7ym9mq",
"team500_yearly_usd": "pri_01knejm9nz0zbvecv0fx5d1red",
}
// CheckoutRequest - user wants to subscribe
type CheckoutRequest struct {
PriceID string `json:"price_id"` // Which plan
CustomerID string `json:"customer_id"` // Our customer ID (if exists)
Email string `json:"email"` // Customer email
VaultL0 string `json:"vault_l0"` // Which vault this pays for
}
// CheckoutResponse - Paddle checkout URL or token
type CheckoutResponse struct {
Success bool `json:"success"`
CheckoutURL string `json:"checkout_url,omitempty"` // For hosted checkout
TransactionID string `json:"transaction_id,omitempty"`
Error string `json:"error,omitempty"`
}
// HandleCheckout creates a Paddle checkout for a new subscription
func HandleCheckout(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req CheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondJSON(w, CheckoutResponse{Success: false, Error: "invalid json"})
return
}
// Validate price ID exists
if req.PriceID == "" {
respondJSON(w, CheckoutResponse{Success: false, Error: "price_id required"})
return
}
// Get or create customer
var customerID string
if req.CustomerID != "" {
// Use existing
customerID = req.CustomerID
} else {
// Create new customer via Paddle
if req.Email == "" {
respondJSON(w, CheckoutResponse{Success: false, Error: "email required for new customer"})
return
}
// Create in our DB + Paddle via dual-write layer
// TODO: Implement using sync.go
customerID = createCustomerViaPaddle(req.Email, "")
}
// Create transaction (invoice) in Paddle
// For automatic collection (credit card): creates checkout
// For manual collection (enterprise): creates invoice
transactionID := createPaddleTransaction(customerID, req.PriceID)
// Store the link between transaction and vault
// So when webhook says "paid", we know which vault to activate
storePendingSubscription(customerID, req.VaultL0, transactionID, req.PriceID)
// Get checkout URL from Paddle
checkoutURL := getPaddleCheckoutURL(transactionID)
respondJSON(w, CheckoutResponse{
Success: true,
CheckoutURL: checkoutURL,
TransactionID: transactionID,
})
}
// HandleCheckoutSuccess - Paddle redirects here after successful payment
func HandleCheckoutSuccess(w http.ResponseWriter, r *http.Request) {
// Paddle redirects to: /checkout/success?transaction_id=txn_xxx&subscription_id=sub_xxx
transactionID := r.URL.Query().Get("transaction_id")
subscriptionID := r.URL.Query().Get("subscription_id")
if transactionID == "" {
http.Error(w, "Missing transaction_id", http.StatusBadRequest)
return
}
// Look up which vault this is for
vaultL0 := getVaultForTransaction(transactionID)
if vaultL0 == "" {
http.Error(w, "Unknown transaction", http.StatusNotFound)
return
}
// Update our records
activateVaultSubscription(vaultL0, subscriptionID, transactionID)
// Redirect to vault with success message
http.Redirect(w, r, "/app/?subscription=active&vault="+vaultL0, http.StatusSeeOther)
}
// WebhookHandler - Paddle sends us events
func HandlePaddleWebhook(w http.ResponseWriter, r *http.Request) {
// Read the raw body BEFORE verification — the signature is over the raw
// bytes, so we can't let json.Decoder rewrite or reformat anything.
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Read error", http.StatusBadRequest)
return
}
if !verifyWebhookSignature(r, body) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Handle empty/malformed events gracefully
if event == nil {
w.WriteHeader(http.StatusOK)
return
}
eventType, _ := event["event_type"].(string)
data, _ := event["data"].(map[string]interface{})
switch eventType {
case "transaction.completed":
// Payment succeeded
if transactionID, ok := data["id"].(string); ok {
activateVaultForTransaction(transactionID)
}
case "transaction.past_due":
// Payment failed
if transactionID, ok := data["id"].(string); ok {
suspendVaultForTransaction(transactionID)
}
case "subscription.canceled":
// Subscription cancelled
if subscriptionID, ok := data["id"].(string); ok {
cancelVaultSubscription(subscriptionID)
}
}
w.WriteHeader(http.StatusOK)
}
// Helper functions (simplified - need implementation)
func createCustomerViaPaddle(email, name string) string {
// Use sync.go dual-write layer
// POST /customers to Paddle
return "ctm_" + generateID()
}
func createPaddleTransaction(customerID, priceID string) string {
// POST /transactions to Paddle
// Returns transaction ID
return "txn_" + generateID()
}
func getPaddleCheckoutURL(transactionID string) string {
// Paddle generates checkout URL
// Or we build it: https://checkout.paddle.com/?transaction=txn_xxx
return "https://checkout.paddle.com/?transaction=" + transactionID
}
func storePendingSubscription(customerID, vaultL0, transactionID, priceID string) {
// Store in DB: pending subscription linking transaction to vault
// When webhook arrives, we know which vault to activate
}
func getVaultForTransaction(transactionID string) string {
// Lookup vault_l0 from pending subscriptions table
return ""
}
func activateVaultSubscription(vaultL0, subscriptionID, transactionID string) {
// Update subscriptions table: status = active
// Update vaults table: subscription_id = subscriptionID
}
func activateVaultForTransaction(transactionID string) {
// Webhook handler - activate the vault
}
func suspendVaultForTransaction(transactionID string) {
// Webhook handler - mark subscription past_due
}
func cancelVaultSubscription(subscriptionID string) {
// Webhook handler - mark cancelled
}
// verifyWebhookSignature verifies the Paddle webhook signature.
//
// Paddle signs webhooks with HMAC-SHA256 over "<timestamp>:<raw_body>". The
// signature header looks like:
//
// Paddle-Signature: ts=1700000000;h1=abcdef0123456789...
//
// We require:
// - PADDLE_WEBHOOK_SECRET env var is set (refuse if missing — no permissive default)
// - the Paddle-Signature header is present and parseable
// - the timestamp is within ±5 minutes of now (anti-replay)
// - hmac-sha256(secret, ts + ":" + body) matches h1 in constant time
//
// Returns false on any failure. SECURITY.md: failures are visible and noisy,
// no silent fallback.
func verifyWebhookSignature(r *http.Request, body []byte) bool {
secret := os.Getenv("PADDLE_WEBHOOK_SECRET")
if secret == "" {
log.Printf("PADDLE_WEBHOOK_SECRET not set; refusing webhook")
return false
}
sigHeader := r.Header.Get("Paddle-Signature")
if sigHeader == "" {
log.Printf("webhook: missing Paddle-Signature header")
return false
}
var ts, h1 string
for _, part := range strings.Split(sigHeader, ";") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "ts":
ts = kv[1]
case "h1":
h1 = kv[1]
}
}
if ts == "" || h1 == "" {
log.Printf("webhook: malformed Paddle-Signature header: %q", sigHeader)
return false
}
// Anti-replay: timestamp within 5 minutes
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
log.Printf("webhook: invalid ts: %v", err)
return false
}
const maxSkew = 300 // 5 minutes
now := time.Now().Unix()
if now-tsInt > maxSkew || tsInt-now > maxSkew {
log.Printf("webhook: timestamp out of range (ts=%d now=%d)", tsInt, now)
return false
}
// Compute HMAC-SHA256(secret, ts + ":" + body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte{':'})
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(h1)) {
log.Printf("webhook: signature mismatch")
return false
}
return true
}
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// respondJSON sends JSON response
func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}