336 lines
9.7 KiB
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)
|
|
}
|