Files
honeyDueAPI/internal/handlers/subscription_webhook_handler.go
treyt e26116e2cf Add webhook logging, pagination, middleware, migrations, and prod hardening
- Webhook event logging repo and subscription webhook idempotency
- Pagination helper (echohelpers) with cursor/offset support
- Request ID and structured logging middleware
- Push client improvements (FCM HTTP v1, better error handling)
- Task model version column, business constraint migrations, targeted indexes
- Expanded categorization chain tests
- Email service and config hardening
- CI workflow updates, .gitignore additions, .env.example updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:32:09 -06:00

821 lines
28 KiB
Go

package handlers
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
)
// SubscriptionWebhookHandler handles subscription webhook callbacks
type SubscriptionWebhookHandler struct {
subscriptionRepo *repositories.SubscriptionRepository
userRepo *repositories.UserRepository
webhookEventRepo *repositories.WebhookEventRepository
appleRootCerts []*x509.Certificate
enabled bool
}
// NewSubscriptionWebhookHandler creates a new webhook handler
func NewSubscriptionWebhookHandler(
subscriptionRepo *repositories.SubscriptionRepository,
userRepo *repositories.UserRepository,
webhookEventRepo *repositories.WebhookEventRepository,
enabled bool,
) *SubscriptionWebhookHandler {
return &SubscriptionWebhookHandler{
subscriptionRepo: subscriptionRepo,
userRepo: userRepo,
webhookEventRepo: webhookEventRepo,
enabled: enabled,
}
}
// ====================
// Apple App Store Server Notifications v2
// ====================
// AppleNotificationPayload represents the outer signed payload from Apple
type AppleNotificationPayload struct {
SignedPayload string `json:"signedPayload"`
}
// AppleNotificationData represents the decoded notification data
type AppleNotificationData struct {
NotificationType string `json:"notificationType"`
Subtype string `json:"subtype"`
NotificationUUID string `json:"notificationUUID"`
Data AppleNotificationDataInner `json:"data"`
Version string `json:"version"`
SignedDate int64 `json:"signedDate"`
}
// AppleNotificationDataInner contains the transaction details
type AppleNotificationDataInner struct {
AppAppleID int64 `json:"appAppleId"`
BundleID string `json:"bundleId"`
BundleVersion string `json:"bundleVersion"`
Environment string `json:"environment"`
SignedTransactionInfo string `json:"signedTransactionInfo"`
SignedRenewalInfo string `json:"signedRenewalInfo"`
}
// AppleTransactionInfo represents decoded transaction info
type AppleTransactionInfo struct {
TransactionID string `json:"transactionId"`
OriginalTransactionID string `json:"originalTransactionId"`
ProductID string `json:"productId"`
PurchaseDate int64 `json:"purchaseDate"`
ExpiresDate int64 `json:"expiresDate"`
Type string `json:"type"`
AppAccountToken string `json:"appAccountToken"` // Your user ID if set during purchase
BundleID string `json:"bundleId"`
Environment string `json:"environment"`
RevocationDate *int64 `json:"revocationDate,omitempty"`
RevocationReason *int `json:"revocationReason,omitempty"`
}
// AppleRenewalInfo represents subscription renewal info
type AppleRenewalInfo struct {
AutoRenewProductID string `json:"autoRenewProductId"`
AutoRenewStatus int `json:"autoRenewStatus"` // 1 = will renew, 0 = turned off
ExpirationIntent int `json:"expirationIntent"`
IsInBillingRetry bool `json:"isInBillingRetryPeriod"`
}
// HandleAppleWebhook handles POST /api/subscription/webhook/apple/
func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
if !h.enabled {
log.Printf("Apple Webhook: webhooks disabled by feature flag")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
log.Printf("Apple Webhook: Failed to read body: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
}
var payload AppleNotificationPayload
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("Apple Webhook: Failed to parse payload: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid payload"})
}
// Decode and verify the signed payload (JWS)
notification, err := h.decodeAppleSignedPayload(payload.SignedPayload)
if err != nil {
log.Printf("Apple Webhook: Failed to decode signed payload: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signed payload"})
}
log.Printf("Apple Webhook: Received %s (subtype: %s) for bundle %s",
notification.NotificationType, notification.Subtype, notification.Data.BundleID)
// Dedup check using notificationUUID
if notification.NotificationUUID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
if err != nil {
log.Printf("Apple Webhook: Failed to check dedup: %v", err)
// Continue processing on dedup check failure (fail-open)
} else if alreadyProcessed {
log.Printf("Apple Webhook: Duplicate event %s, skipping", notification.NotificationUUID)
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
}
}
// Verify bundle ID matches our app
cfg := config.Get()
if cfg != nil && cfg.AppleIAP.BundleID != "" {
if notification.Data.BundleID != cfg.AppleIAP.BundleID {
log.Printf("Apple Webhook: Bundle ID mismatch: got %s, expected %s",
notification.Data.BundleID, cfg.AppleIAP.BundleID)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "bundle ID mismatch"})
}
}
// Decode transaction info
transactionInfo, err := h.decodeAppleTransaction(notification.Data.SignedTransactionInfo)
if err != nil {
log.Printf("Apple Webhook: Failed to decode transaction: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid transaction info"})
}
// Decode renewal info if present
var renewalInfo *AppleRenewalInfo
if notification.Data.SignedRenewalInfo != "" {
renewalInfo, _ = h.decodeAppleRenewalInfo(notification.Data.SignedRenewalInfo)
}
// Process the notification
if err := h.processAppleNotification(notification, transactionInfo, renewalInfo); err != nil {
log.Printf("Apple Webhook: Failed to process notification: %v", err)
// Still return 200 to prevent Apple from retrying
}
// Record processed event for dedup
if notification.NotificationUUID != "" {
if err := h.webhookEventRepo.RecordEvent("apple", notification.NotificationUUID, notification.NotificationType, ""); err != nil {
log.Printf("Apple Webhook: Failed to record event: %v", err)
}
}
// Always return 200 OK to acknowledge receipt
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// decodeAppleSignedPayload decodes and verifies an Apple JWS payload
func (h *SubscriptionWebhookHandler) decodeAppleSignedPayload(signedPayload string) (*AppleNotificationData, error) {
// JWS format: header.payload.signature
parts := strings.Split(signedPayload, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWS format")
}
// Decode payload (we're trusting Apple's signature for now)
// In production, you should verify the signature using Apple's root certificate
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
}
var notification AppleNotificationData
if err := json.Unmarshal(payload, &notification); err != nil {
return nil, fmt.Errorf("failed to parse notification: %w", err)
}
return &notification, nil
}
// decodeAppleTransaction decodes a signed transaction info JWS
func (h *SubscriptionWebhookHandler) decodeAppleTransaction(signedTransaction string) (*AppleTransactionInfo, error) {
parts := strings.Split(signedTransaction, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWS format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
}
var info AppleTransactionInfo
if err := json.Unmarshal(payload, &info); err != nil {
return nil, fmt.Errorf("failed to parse transaction info: %w", err)
}
return &info, nil
}
// decodeAppleRenewalInfo decodes signed renewal info JWS
func (h *SubscriptionWebhookHandler) decodeAppleRenewalInfo(signedRenewal string) (*AppleRenewalInfo, error) {
parts := strings.Split(signedRenewal, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWS format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
}
var info AppleRenewalInfo
if err := json.Unmarshal(payload, &info); err != nil {
return nil, fmt.Errorf("failed to parse renewal info: %w", err)
}
return &info, nil
}
// processAppleNotification handles the business logic for Apple notifications
func (h *SubscriptionWebhookHandler) processAppleNotification(
notification *AppleNotificationData,
transaction *AppleTransactionInfo,
renewal *AppleRenewalInfo,
) error {
// Find user by stored receipt data (original transaction ID)
user, err := h.findUserByAppleTransaction(transaction.OriginalTransactionID)
if err != nil {
log.Printf("Apple Webhook: Could not find user for transaction %s: %v",
transaction.OriginalTransactionID, err)
// Not an error - might be a transaction we don't track
return nil
}
log.Printf("Apple Webhook: Processing %s for user %d (product: %s)",
notification.NotificationType, user.ID, transaction.ProductID)
switch notification.NotificationType {
case "SUBSCRIBED":
// New subscription or resubscription
return h.handleAppleSubscribed(user.ID, transaction, renewal)
case "DID_RENEW":
// Subscription successfully renewed
return h.handleAppleRenewed(user.ID, transaction, renewal)
case "DID_CHANGE_RENEWAL_STATUS":
// User turned auto-renew on/off
return h.handleAppleRenewalStatusChange(user.ID, transaction, renewal)
case "DID_FAIL_TO_RENEW":
// Billing issue - subscription may still be in grace period
return h.handleAppleFailedToRenew(user.ID, transaction, renewal)
case "EXPIRED":
// Subscription expired
return h.handleAppleExpired(user.ID, transaction)
case "REFUND":
// User got a refund
return h.handleAppleRefund(user.ID, transaction)
case "REVOKE":
// Family sharing revoked or refund
return h.handleAppleRevoke(user.ID, transaction)
case "GRACE_PERIOD_EXPIRED":
// Grace period ended without successful billing
return h.handleAppleGracePeriodExpired(user.ID, transaction)
default:
log.Printf("Apple Webhook: Unhandled notification type: %s", notification.NotificationType)
}
return nil
}
func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) {
// Look up user subscription by stored receipt data
subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID)
if err != nil {
return nil, err
}
user, err := h.userRepo.FindByID(subscription.UserID)
if err != nil {
return nil, err
}
return user, nil
}
func (h *SubscriptionWebhookHandler) handleAppleSubscribed(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error {
expiresAt := time.Unix(tx.ExpiresDate/1000, 0)
autoRenew := renewal != nil && renewal.AutoRenewStatus == 1
if err := h.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return err
}
if err := h.subscriptionRepo.SetAutoRenew(userID, autoRenew); err != nil {
return err
}
log.Printf("Apple Webhook: User %d subscribed, expires %v, autoRenew=%v", userID, expiresAt, autoRenew)
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleRenewed(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error {
expiresAt := time.Unix(tx.ExpiresDate/1000, 0)
if err := h.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return err
}
log.Printf("Apple Webhook: User %d renewed, new expiry %v", userID, expiresAt)
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleRenewalStatusChange(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error {
if renewal == nil {
return nil
}
autoRenew := renewal.AutoRenewStatus == 1
if err := h.subscriptionRepo.SetAutoRenew(userID, autoRenew); err != nil {
return err
}
if !autoRenew {
// User turned off auto-renew (will cancel at end of period)
now := time.Now().UTC()
if err := h.subscriptionRepo.SetCancelledAt(userID, now); err != nil {
return err
}
log.Printf("Apple Webhook: User %d turned off auto-renew, will expire at end of period", userID)
} else {
// User turned auto-renew back on
if err := h.subscriptionRepo.ClearCancelledAt(userID); err != nil {
return err
}
log.Printf("Apple Webhook: User %d turned auto-renew back on", userID)
}
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleFailedToRenew(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error {
// Subscription is in billing retry or grace period
log.Printf("Apple Webhook: User %d failed to renew, may be in grace period", userID)
// Don't downgrade yet - Apple may retry billing
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleExpired(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
return err
}
log.Printf("Apple Webhook: User %d subscription expired, downgraded to free", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleRefund(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
return err
}
log.Printf("Apple Webhook: User %d got refund, downgraded to free", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleRevoke(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
return err
}
log.Printf("Apple Webhook: User %d subscription revoked, downgraded to free", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleGracePeriodExpired(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
return err
}
log.Printf("Apple Webhook: User %d grace period expired, downgraded to free", userID)
return nil
}
// ====================
// Google Real-time Developer Notifications
// ====================
// GoogleNotification represents a Google Pub/Sub push message
type GoogleNotification struct {
Message GooglePubSubMessage `json:"message"`
Subscription string `json:"subscription"`
}
// GooglePubSubMessage represents the Pub/Sub message wrapper
type GooglePubSubMessage struct {
Data string `json:"data"` // Base64 encoded
MessageID string `json:"messageId"`
PublishTime string `json:"publishTime"`
Attributes map[string]string `json:"attributes"`
}
// GoogleDeveloperNotification represents the decoded notification
type GoogleDeveloperNotification struct {
Version string `json:"version"`
PackageName string `json:"packageName"`
EventTimeMillis string `json:"eventTimeMillis"`
SubscriptionNotification *GoogleSubscriptionNotification `json:"subscriptionNotification"`
OneTimeProductNotification *GoogleOneTimeNotification `json:"oneTimeProductNotification"`
TestNotification *GoogleTestNotification `json:"testNotification"`
}
// GoogleSubscriptionNotification represents subscription-specific data
type GoogleSubscriptionNotification struct {
Version string `json:"version"`
NotificationType int `json:"notificationType"`
PurchaseToken string `json:"purchaseToken"`
SubscriptionID string `json:"subscriptionId"`
}
// GoogleOneTimeNotification represents one-time purchase data
type GoogleOneTimeNotification struct {
Version string `json:"version"`
NotificationType int `json:"notificationType"`
PurchaseToken string `json:"purchaseToken"`
SKU string `json:"sku"`
}
// GoogleTestNotification represents a test notification
type GoogleTestNotification struct {
Version string `json:"version"`
}
// Google subscription notification types
const (
GoogleSubRecovered = 1 // Subscription recovered from account hold
GoogleSubRenewed = 2 // Active subscription renewed
GoogleSubCanceled = 3 // Subscription was cancelled (voluntary or involuntary)
GoogleSubPurchased = 4 // New subscription purchased
GoogleSubOnHold = 5 // Subscription entered account hold
GoogleSubInGracePeriod = 6 // Subscription entered grace period
GoogleSubRestarted = 7 // User reactivated subscription
GoogleSubPriceChangeConfirmed = 8 // Price change confirmed by user
GoogleSubDeferred = 9 // Subscription deferred
GoogleSubPaused = 10 // Subscription paused
GoogleSubPauseScheduleChanged = 11 // Pause schedule changed
GoogleSubRevoked = 12 // Subscription revoked
GoogleSubExpired = 13 // Subscription expired
)
// HandleGoogleWebhook handles POST /api/subscription/webhook/google/
func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
if !h.enabled {
log.Printf("Google Webhook: webhooks disabled by feature flag")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
log.Printf("Google Webhook: Failed to read body: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
}
var notification GoogleNotification
if err := json.Unmarshal(body, &notification); err != nil {
log.Printf("Google Webhook: Failed to parse notification: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid notification"})
}
// Decode the base64 data
data, err := base64.StdEncoding.DecodeString(notification.Message.Data)
if err != nil {
log.Printf("Google Webhook: Failed to decode message data: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid message data"})
}
var devNotification GoogleDeveloperNotification
if err := json.Unmarshal(data, &devNotification); err != nil {
log.Printf("Google Webhook: Failed to parse developer notification: %v", err)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid developer notification"})
}
// Dedup check using messageId
messageID := notification.Message.MessageID
if messageID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
if err != nil {
log.Printf("Google Webhook: Failed to check dedup: %v", err)
// Continue processing on dedup check failure (fail-open)
} else if alreadyProcessed {
log.Printf("Google Webhook: Duplicate event %s, skipping", messageID)
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
}
}
// Handle test notification
if devNotification.TestNotification != nil {
log.Printf("Google Webhook: Received test notification")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "test received"})
}
// Verify package name
cfg := config.Get()
if cfg != nil && cfg.GoogleIAP.PackageName != "" {
if devNotification.PackageName != cfg.GoogleIAP.PackageName {
log.Printf("Google Webhook: Package name mismatch: got %s, expected %s",
devNotification.PackageName, cfg.GoogleIAP.PackageName)
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "package name mismatch"})
}
}
// Process subscription notification
if devNotification.SubscriptionNotification != nil {
if err := h.processGoogleSubscriptionNotification(devNotification.SubscriptionNotification); err != nil {
log.Printf("Google Webhook: Failed to process notification: %v", err)
// Still return 200 to acknowledge
}
}
// Record processed event for dedup
if messageID != "" {
eventType := "unknown"
if devNotification.SubscriptionNotification != nil {
eventType = fmt.Sprintf("subscription_%d", devNotification.SubscriptionNotification.NotificationType)
}
if err := h.webhookEventRepo.RecordEvent("google", messageID, eventType, ""); err != nil {
log.Printf("Google Webhook: Failed to record event: %v", err)
}
}
// Acknowledge the message
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// processGoogleSubscriptionNotification handles Google subscription events
func (h *SubscriptionWebhookHandler) processGoogleSubscriptionNotification(notification *GoogleSubscriptionNotification) error {
// Find user by purchase token
user, err := h.findUserByGoogleToken(notification.PurchaseToken)
if err != nil {
log.Printf("Google Webhook: Could not find user for token: %v", err)
return nil // Not an error - might be unknown token
}
log.Printf("Google Webhook: Processing type %d for user %d (subscription: %s)",
notification.NotificationType, user.ID, notification.SubscriptionID)
switch notification.NotificationType {
case GoogleSubPurchased:
return h.handleGooglePurchased(user.ID, notification)
case GoogleSubRenewed:
return h.handleGoogleRenewed(user.ID, notification)
case GoogleSubRecovered:
return h.handleGoogleRecovered(user.ID, notification)
case GoogleSubCanceled:
return h.handleGoogleCanceled(user.ID, notification)
case GoogleSubOnHold:
return h.handleGoogleOnHold(user.ID, notification)
case GoogleSubInGracePeriod:
return h.handleGoogleGracePeriod(user.ID, notification)
case GoogleSubRestarted:
return h.handleGoogleRestarted(user.ID, notification)
case GoogleSubRevoked:
return h.handleGoogleRevoked(user.ID, notification)
case GoogleSubExpired:
return h.handleGoogleExpired(user.ID, notification)
case GoogleSubPaused:
return h.handleGooglePaused(user.ID, notification)
default:
log.Printf("Google Webhook: Unhandled notification type: %d", notification.NotificationType)
}
return nil
}
func (h *SubscriptionWebhookHandler) findUserByGoogleToken(purchaseToken string) (*models.User, error) {
subscription, err := h.subscriptionRepo.FindByGoogleToken(purchaseToken)
if err != nil {
return nil, err
}
user, err := h.userRepo.FindByID(subscription.UserID)
if err != nil {
return nil, err
}
return user, nil
}
func (h *SubscriptionWebhookHandler) handleGooglePurchased(userID uint, notification *GoogleSubscriptionNotification) error {
// New subscription - we should have already processed this via the client
// This is a backup notification
log.Printf("Google Webhook: User %d purchased subscription %s", userID, notification.SubscriptionID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleRenewed(userID uint, notification *GoogleSubscriptionNotification) error {
// Need to query Google API for new expiry date
// For now, extend by typical period (1 month for monthly, 1 year for yearly)
var extension time.Duration
if strings.Contains(notification.SubscriptionID, "monthly") {
extension = 30 * 24 * time.Hour
} else {
extension = 365 * 24 * time.Hour
}
newExpiry := time.Now().UTC().Add(extension)
if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil {
return err
}
log.Printf("Google Webhook: User %d renewed, extended to %v", userID, newExpiry)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleRecovered(userID uint, notification *GoogleSubscriptionNotification) error {
// Subscription recovered from account hold - reactivate
newExpiry := time.Now().UTC().AddDate(0, 1, 0) // 1 month from now
if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil {
return err
}
log.Printf("Google Webhook: User %d subscription recovered", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleCanceled(userID uint, notification *GoogleSubscriptionNotification) error {
// User canceled - will expire at end of period
now := time.Now().UTC()
if err := h.subscriptionRepo.SetCancelledAt(userID, now); err != nil {
return err
}
if err := h.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
return err
}
log.Printf("Google Webhook: User %d canceled, will expire at end of period", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleOnHold(userID uint, notification *GoogleSubscriptionNotification) error {
// Account hold - payment issue, may recover
log.Printf("Google Webhook: User %d subscription on hold", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleGracePeriod(userID uint, notification *GoogleSubscriptionNotification) error {
// In grace period - user still has access but billing failed
log.Printf("Google Webhook: User %d in grace period", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleRestarted(userID uint, notification *GoogleSubscriptionNotification) error {
// User restarted subscription
newExpiry := time.Now().UTC().AddDate(0, 1, 0)
if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil {
return err
}
if err := h.subscriptionRepo.ClearCancelledAt(userID); err != nil {
return err
}
if err := h.subscriptionRepo.SetAutoRenew(userID, true); err != nil {
return err
}
log.Printf("Google Webhook: User %d restarted subscription", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleRevoked(userID uint, notification *GoogleSubscriptionNotification) error {
// Subscription revoked - immediate downgrade
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
return err
}
log.Printf("Google Webhook: User %d subscription revoked", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleExpired(userID uint, notification *GoogleSubscriptionNotification) error {
// Subscription expired
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
return err
}
log.Printf("Google Webhook: User %d subscription expired", userID)
return nil
}
func (h *SubscriptionWebhookHandler) handleGooglePaused(userID uint, notification *GoogleSubscriptionNotification) error {
// Subscription paused by user
log.Printf("Google Webhook: User %d subscription paused", userID)
return nil
}
// ====================
// Signature Verification (Optional but Recommended)
// ====================
// VerifyAppleSignature verifies the JWS signature using Apple's root certificate
// This is optional but recommended for production
func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string) error {
// Load Apple's root certificate if not already loaded
if h.appleRootCerts == nil {
// Apple's root certificates can be downloaded from:
// https://www.apple.com/certificateauthority/
// You'd typically embed these or load from a file
return nil // Skip verification for now
}
// Parse the JWS token
token, err := jwt.Parse(signedPayload, func(token *jwt.Token) (interface{}, error) {
// Get the x5c header (certificate chain)
x5c, ok := token.Header["x5c"].([]interface{})
if !ok || len(x5c) == 0 {
return nil, fmt.Errorf("missing x5c header")
}
// Decode the first certificate (leaf)
certData, err := base64.StdEncoding.DecodeString(x5c[0].(string))
if err != nil {
return nil, fmt.Errorf("failed to decode certificate: %w", err)
}
cert, err := x509.ParseCertificate(certData)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Verify the certificate chain (simplified)
// In production, you should verify the full chain
return cert.PublicKey.(*ecdsa.PublicKey), nil
})
if err != nil {
return fmt.Errorf("failed to verify signature: %w", err)
}
if !token.Valid {
return fmt.Errorf("invalid token")
}
return nil
}
// VerifyGooglePubSubToken verifies the Pub/Sub push token (if configured)
func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c echo.Context) bool {
// If you configured a push endpoint with authentication, verify here
// The token is typically in the Authorization header
// For now, we rely on the endpoint being protected by your infrastructure
// (e.g., only accessible from Google's IP ranges)
return true
}
// Helper function to load Apple root certificates from file
func loadAppleRootCertificates(certPath string) ([]*x509.Certificate, error) {
data, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
var certs []*x509.Certificate
for {
block, rest := pem.Decode(data)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
certs = append(certs, cert)
}
data = rest
}
return certs, nil
}