- Stripe integration: add StripeService with checkout sessions, customer portal, and webhook handling for subscription lifecycle events. - Free trials: auto-start configurable trial on first subscription check, with admin-controllable duration and enable/disable toggle. - Cross-platform guard: prevent duplicate subscriptions across iOS, Android, and Stripe by checking existing platform before allowing purchase. - Subscription model: add Stripe fields (customer_id, subscription_id, price_id), trial fields (trial_start, trial_end, trial_used), and SubscriptionSource/IsTrialActive helpers. - API: add trial and source fields to status response, update OpenAPI spec. - Clean up stale migration and audit docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
967 lines
35 KiB
Go
967 lines
35 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/treytartt/casera-api/internal/config"
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
"github.com/treytartt/casera-api/internal/repositories"
|
|
"github.com/treytartt/casera-api/internal/services"
|
|
)
|
|
|
|
// SubscriptionWebhookHandler handles subscription webhook callbacks
|
|
type SubscriptionWebhookHandler struct {
|
|
subscriptionRepo *repositories.SubscriptionRepository
|
|
userRepo *repositories.UserRepository
|
|
webhookEventRepo *repositories.WebhookEventRepository
|
|
appleRootCerts []*x509.Certificate
|
|
stripeService *services.StripeService
|
|
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,
|
|
}
|
|
}
|
|
|
|
// SetStripeService sets the Stripe service for webhook handling
|
|
func (h *SubscriptionWebhookHandler) SetStripeService(stripeService *services.StripeService) {
|
|
h.stripeService = stripeService
|
|
}
|
|
|
|
// ====================
|
|
// 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.Info().Msg("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.Error().Err(err).Msg("Apple Webhook: Failed to read body")
|
|
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.Error().Err(err).Msg("Apple Webhook: Failed to parse payload")
|
|
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.Error().Err(err).Msg("Apple Webhook: Failed to decode signed payload")
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signed payload"})
|
|
}
|
|
|
|
log.Info().Str("type", notification.NotificationType).Str("subtype", notification.Subtype).Str("bundle", notification.Data.BundleID).Msg("Apple Webhook: Received notification")
|
|
|
|
// Dedup check using notificationUUID
|
|
if notification.NotificationUUID != "" {
|
|
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup")
|
|
// Continue processing on dedup check failure (fail-open)
|
|
} else if alreadyProcessed {
|
|
log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping")
|
|
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.Warn().Str("got", notification.Data.BundleID).Str("expected", cfg.AppleIAP.BundleID).Msg("Apple Webhook: Bundle ID mismatch")
|
|
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.Error().Err(err).Msg("Apple Webhook: Failed to decode transaction")
|
|
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.Error().Err(err).Msg("Apple Webhook: Failed to process notification")
|
|
// 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.Error().Err(err).Msg("Apple Webhook: Failed to record event")
|
|
}
|
|
}
|
|
|
|
// Always return 200 OK to acknowledge receipt
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
|
|
}
|
|
|
|
// decodeAppleSignedPayload verifies and decodes an Apple JWS payload.
|
|
// The JWS signature is verified before the payload is trusted.
|
|
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")
|
|
}
|
|
|
|
// Verify the JWS signature before trusting the payload.
|
|
if err := h.VerifyAppleSignature(signedPayload); err != nil {
|
|
return nil, fmt.Errorf("Apple JWS signature verification failed: %w", err)
|
|
}
|
|
|
|
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, ¬ification); err != nil {
|
|
return nil, fmt.Errorf("failed to parse notification: %w", err)
|
|
}
|
|
|
|
return ¬ification, 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.Warn().Err(err).Str("transaction_id", transaction.OriginalTransactionID).Msg("Apple Webhook: Could not find user for transaction")
|
|
// Not an error - might be a transaction we don't track
|
|
return nil
|
|
}
|
|
|
|
log.Info().Str("type", notification.NotificationType).Uint("user_id", user.ID).Str("product", transaction.ProductID).Msg("Apple Webhook: Processing notification")
|
|
|
|
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.Warn().Str("type", notification.NotificationType).Msg("Apple Webhook: Unhandled notification type")
|
|
}
|
|
|
|
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.Info().Uint("user_id", userID).Time("expires", expiresAt).Bool("auto_renew", autoRenew).Msg("Apple Webhook: User subscribed")
|
|
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.Info().Uint("user_id", userID).Time("expires", expiresAt).Msg("Apple Webhook: User renewed")
|
|
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.Info().Uint("user_id", userID).Msg("Apple Webhook: User turned off auto-renew, will expire at end of period")
|
|
} else {
|
|
// User turned auto-renew back on
|
|
if err := h.subscriptionRepo.ClearCancelledAt(userID); err != nil {
|
|
return err
|
|
}
|
|
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User turned auto-renew back on")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleAppleFailedToRenew(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error {
|
|
// Subscription is in billing retry or grace period
|
|
log.Warn().Uint("user_id", userID).Msg("Apple Webhook: User failed to renew, may be in grace period")
|
|
// Don't downgrade yet - Apple may retry billing
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleAppleExpired(userID uint, tx *AppleTransactionInfo) error {
|
|
if err := h.safeDowngradeToFree(userID, "Apple expired"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleAppleRefund(userID uint, tx *AppleTransactionInfo) error {
|
|
if err := h.safeDowngradeToFree(userID, "Apple refund"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleAppleRevoke(userID uint, tx *AppleTransactionInfo) error {
|
|
if err := h.safeDowngradeToFree(userID, "Apple revoke"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleAppleGracePeriodExpired(userID uint, tx *AppleTransactionInfo) error {
|
|
if err := h.safeDowngradeToFree(userID, "Apple grace period expired"); err != nil {
|
|
return err
|
|
}
|
|
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.Info().Msg("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.Error().Err(err).Msg("Google Webhook: Failed to read body")
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
|
|
}
|
|
|
|
var notification GoogleNotification
|
|
if err := json.Unmarshal(body, ¬ification); err != nil {
|
|
log.Error().Err(err).Msg("Google Webhook: Failed to parse notification")
|
|
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.Error().Err(err).Msg("Google Webhook: Failed to decode message data")
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid message data"})
|
|
}
|
|
|
|
var devNotification GoogleDeveloperNotification
|
|
if err := json.Unmarshal(data, &devNotification); err != nil {
|
|
log.Error().Err(err).Msg("Google Webhook: Failed to parse developer notification")
|
|
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.Error().Err(err).Msg("Google Webhook: Failed to check dedup")
|
|
// Continue processing on dedup check failure (fail-open)
|
|
} else if alreadyProcessed {
|
|
log.Info().Str("message_id", messageID).Msg("Google Webhook: Duplicate event, skipping")
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
|
|
}
|
|
}
|
|
|
|
// Handle test notification
|
|
if devNotification.TestNotification != nil {
|
|
log.Info().Msg("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.Warn().Str("got", devNotification.PackageName).Str("expected", cfg.GoogleIAP.PackageName).Msg("Google Webhook: Package name mismatch")
|
|
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.Error().Err(err).Msg("Google Webhook: Failed to process notification")
|
|
// 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.Error().Err(err).Msg("Google Webhook: Failed to record event")
|
|
}
|
|
}
|
|
|
|
// 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.Warn().Err(err).Msg("Google Webhook: Could not find user for token")
|
|
return nil // Not an error - might be unknown token
|
|
}
|
|
|
|
log.Info().Int("type", notification.NotificationType).Uint("user_id", user.ID).Str("subscription", notification.SubscriptionID).Msg("Google Webhook: Processing notification")
|
|
|
|
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.Warn().Int("type", notification.NotificationType).Msg("Google Webhook: Unhandled notification type")
|
|
}
|
|
|
|
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.Info().Uint("user_id", userID).Str("subscription", notification.SubscriptionID).Msg("Google Webhook: User purchased subscription")
|
|
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.Info().Uint("user_id", userID).Time("expires", newExpiry).Msg("Google Webhook: User renewed")
|
|
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.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription recovered")
|
|
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.Info().Uint("user_id", userID).Msg("Google Webhook: User canceled, will expire at end of period")
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleGoogleOnHold(userID uint, notification *GoogleSubscriptionNotification) error {
|
|
// Account hold - payment issue, may recover
|
|
log.Warn().Uint("user_id", userID).Msg("Google Webhook: User subscription on hold")
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleGoogleGracePeriod(userID uint, notification *GoogleSubscriptionNotification) error {
|
|
// In grace period - user still has access but billing failed
|
|
log.Warn().Uint("user_id", userID).Msg("Google Webhook: User in grace period")
|
|
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.Info().Uint("user_id", userID).Msg("Google Webhook: User restarted subscription")
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleGoogleRevoked(userID uint, notification *GoogleSubscriptionNotification) error {
|
|
if err := h.safeDowngradeToFree(userID, "Google revoke"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleGoogleExpired(userID uint, notification *GoogleSubscriptionNotification) error {
|
|
if err := h.safeDowngradeToFree(userID, "Google expired"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *SubscriptionWebhookHandler) handleGooglePaused(userID uint, notification *GoogleSubscriptionNotification) error {
|
|
// Subscription paused by user
|
|
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription paused")
|
|
return nil
|
|
}
|
|
|
|
// ====================
|
|
// Multi-Source Downgrade Safety
|
|
// ====================
|
|
|
|
// safeDowngradeToFree checks if the user has active subscriptions from other sources
|
|
// before downgrading to free. If another source is still active, skip the downgrade.
|
|
func (h *SubscriptionWebhookHandler) safeDowngradeToFree(userID uint, reason string) error {
|
|
sub, err := h.subscriptionRepo.FindByUserID(userID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Could not find subscription for multi-source check, proceeding with downgrade")
|
|
return h.subscriptionRepo.DowngradeToFree(userID)
|
|
}
|
|
|
|
// Check if Stripe subscription is still active
|
|
if sub.HasStripeSubscription() && sub.Platform != models.PlatformStripe {
|
|
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Stripe subscription")
|
|
return nil
|
|
}
|
|
// Check if Apple subscription is still active (for Google/Stripe webhooks)
|
|
if sub.HasAppleSubscription() && sub.Platform != models.PlatformIOS {
|
|
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Apple subscription")
|
|
return nil
|
|
}
|
|
// Check if Google subscription is still active (for Apple/Stripe webhooks)
|
|
if sub.HasGoogleSubscription() && sub.Platform != models.PlatformAndroid {
|
|
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Google subscription")
|
|
return nil
|
|
}
|
|
// Check if trial is still active
|
|
if sub.IsTrialActive() {
|
|
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active trial")
|
|
return nil
|
|
}
|
|
|
|
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: User downgraded to free (no other active sources)")
|
|
return nil
|
|
}
|
|
|
|
// ====================
|
|
// Stripe Webhooks
|
|
// ====================
|
|
|
|
// HandleStripeWebhook handles POST /api/subscription/webhook/stripe/
|
|
func (h *SubscriptionWebhookHandler) HandleStripeWebhook(c echo.Context) error {
|
|
if !h.enabled {
|
|
log.Info().Msg("Stripe Webhook: webhooks disabled by feature flag")
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
|
|
}
|
|
|
|
if h.stripeService == nil {
|
|
log.Warn().Msg("Stripe Webhook: Stripe service not configured")
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"status": "not_configured"})
|
|
}
|
|
|
|
body, err := io.ReadAll(c.Request().Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Stripe Webhook: Failed to read body")
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
|
|
}
|
|
|
|
signature := c.Request().Header.Get("Stripe-Signature")
|
|
if signature == "" {
|
|
log.Warn().Msg("Stripe Webhook: Missing Stripe-Signature header")
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "missing signature"})
|
|
}
|
|
|
|
if err := h.stripeService.HandleWebhookEvent(body, signature); err != nil {
|
|
log.Error().Err(err).Msg("Stripe Webhook: Failed to process webhook")
|
|
// Still return 200 to prevent Stripe from retrying on business logic errors
|
|
// Only return error for signature verification failures
|
|
if strings.Contains(err.Error(), "signature") {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signature"})
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
|
|
}
|
|
|
|
// ====================
|
|
// Signature Verification (Optional but Recommended)
|
|
// ====================
|
|
|
|
// VerifyAppleSignature verifies the JWS signature using Apple's root certificate.
|
|
// If root certificates are not loaded, verification fails (deny by default).
|
|
func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string) error {
|
|
// Deny by default when root certificates are not loaded.
|
|
if h.appleRootCerts == nil {
|
|
return fmt.Errorf("Apple root certificates not configured: cannot verify JWS signature")
|
|
}
|
|
|
|
// Build a certificate pool from the loaded Apple root certificates
|
|
rootPool := x509.NewCertPool()
|
|
for _, cert := range h.appleRootCerts {
|
|
rootPool.AddCert(cert)
|
|
}
|
|
|
|
// Parse the JWS token and verify the signature using the x5c certificate chain
|
|
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 leaf certificate
|
|
certData, err := base64.StdEncoding.DecodeString(x5c[0].(string))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode certificate: %w", err)
|
|
}
|
|
|
|
leafCert, err := x509.ParseCertificate(certData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
|
}
|
|
|
|
// Build intermediate pool from remaining x5c entries
|
|
intermediatePool := x509.NewCertPool()
|
|
for i := 1; i < len(x5c); i++ {
|
|
intermData, err := base64.StdEncoding.DecodeString(x5c[i].(string))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode intermediate certificate: %w", err)
|
|
}
|
|
intermCert, err := x509.ParseCertificate(intermData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse intermediate certificate: %w", err)
|
|
}
|
|
intermediatePool.AddCert(intermCert)
|
|
}
|
|
|
|
// Verify the certificate chain against Apple's root certificates
|
|
opts := x509.VerifyOptions{
|
|
Roots: rootPool,
|
|
Intermediates: intermediatePool,
|
|
}
|
|
if _, err := leafCert.Verify(opts); err != nil {
|
|
return nil, fmt.Errorf("certificate chain verification failed: %w", err)
|
|
}
|
|
|
|
ecdsaKey, ok := leafCert.PublicKey.(*ecdsa.PublicKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("leaf certificate public key is not ECDSA")
|
|
}
|
|
|
|
return ecdsaKey, 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 authentication token.
|
|
// Returns false (deny) when the Authorization header is missing or the token
|
|
// cannot be validated. This prevents unauthenticated callers from injecting
|
|
// webhook events.
|
|
func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c echo.Context) bool {
|
|
authHeader := c.Request().Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
log.Warn().Msg("Google Webhook: missing Authorization header")
|
|
return false
|
|
}
|
|
|
|
// Expect "Bearer <token>" format
|
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
|
log.Warn().Msg("Google Webhook: Authorization header is not Bearer token")
|
|
return false
|
|
}
|
|
|
|
bearerToken := strings.TrimPrefix(authHeader, "Bearer ")
|
|
if bearerToken == "" {
|
|
log.Warn().Msg("Google Webhook: empty Bearer token")
|
|
return false
|
|
}
|
|
|
|
// Parse the token as a JWT. Google Pub/Sub push tokens are signed JWTs
|
|
// issued by accounts.google.com. We verify the claims to ensure the
|
|
// token was intended for our service.
|
|
token, _, err := jwt.NewParser().ParseUnverified(bearerToken, jwt.MapClaims{})
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Google Webhook: failed to parse Bearer token")
|
|
return false
|
|
}
|
|
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
log.Warn().Msg("Google Webhook: invalid token claims")
|
|
return false
|
|
}
|
|
|
|
// Verify issuer is Google
|
|
issuer, _ := claims.GetIssuer()
|
|
if issuer != "accounts.google.com" && issuer != "https://accounts.google.com" {
|
|
log.Warn().Str("issuer", issuer).Msg("Google Webhook: unexpected issuer")
|
|
return false
|
|
}
|
|
|
|
// Verify the email claim matches a Google service account
|
|
email, _ := claims["email"].(string)
|
|
if email == "" || !strings.HasSuffix(email, ".gserviceaccount.com") {
|
|
log.Warn().Str("email", email).Msg("Google Webhook: token email is not a Google service account")
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|