Add Apple/Google IAP validation and subscription webhooks

- Add Apple App Store Server API integration for receipt/transaction validation
- Add Google Play Developer API integration for purchase token validation
- Add webhook endpoints for server-to-server subscription notifications
  - POST /api/subscription/webhook/apple/ (App Store Server Notifications v2)
  - POST /api/subscription/webhook/google/ (Real-time Developer Notifications)
- Support both StoreKit 1 (receipt_data) and StoreKit 2 (transaction_id)
- Add repository methods to find users by transaction ID or purchase token
- Add configuration for IAP credentials (APPLE_IAP_*, GOOGLE_IAP_*)
- Add setup documentation for configuring webhooks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-14 13:58:37 -06:00
parent 81885c4ea3
commit c58aaa5d5f
10 changed files with 1909 additions and 52 deletions

View File

@@ -1,11 +1,14 @@
package services
import (
"context"
"errors"
"log"
"time"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
)
@@ -21,6 +24,15 @@ var (
ErrPromotionNotFound = errors.New("promotion not found")
)
// KnownSubscriptionIDs are the product IDs for Pro subscriptions
// Update these to match your actual App Store Connect / Google Play Console product IDs
var KnownSubscriptionIDs = []string{
"com.tt.casera.pro.monthly",
"com.tt.casera.pro.yearly",
"casera_pro_monthly",
"casera_pro_yearly",
}
// SubscriptionService handles subscription business logic
type SubscriptionService struct {
subscriptionRepo *repositories.SubscriptionRepository
@@ -28,6 +40,8 @@ type SubscriptionService struct {
taskRepo *repositories.TaskRepository
contractorRepo *repositories.ContractorRepository
documentRepo *repositories.DocumentRepository
appleClient *AppleIAPClient
googleClient *GoogleIAPClient
}
// NewSubscriptionService creates a new subscription service
@@ -38,13 +52,41 @@ func NewSubscriptionService(
contractorRepo *repositories.ContractorRepository,
documentRepo *repositories.DocumentRepository,
) *SubscriptionService {
return &SubscriptionService{
svc := &SubscriptionService{
subscriptionRepo: subscriptionRepo,
residenceRepo: residenceRepo,
taskRepo: taskRepo,
contractorRepo: contractorRepo,
documentRepo: documentRepo,
}
// Initialize Apple IAP client
cfg := config.Get()
if cfg != nil {
appleClient, err := NewAppleIAPClient(cfg.AppleIAP)
if err != nil {
if !errors.Is(err, ErrIAPNotConfigured) {
log.Printf("Warning: Failed to initialize Apple IAP client: %v", err)
}
} else {
svc.appleClient = appleClient
log.Println("Apple IAP validation client initialized")
}
// Initialize Google IAP client
ctx := context.Background()
googleClient, err := NewGoogleIAPClient(ctx, cfg.GoogleIAP)
if err != nil {
if !errors.Is(err, ErrIAPNotConfigured) {
log.Printf("Warning: Failed to initialize Google IAP client: %v", err)
}
} else {
svc.googleClient = googleClient
log.Println("Google IAP validation client initialized")
}
}
return svc
}
// GetSubscription gets the subscription for a user
@@ -281,17 +323,57 @@ func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionRespo
}
// ProcessApplePurchase processes an Apple IAP purchase
func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string) (*SubscriptionResponse, error) {
// TODO: Implement receipt validation with Apple's servers
// For now, just upgrade the user
// Store receipt data
if err := s.subscriptionRepo.UpdateReceiptData(userID, receiptData); err != nil {
// Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID)
func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) {
// Store receipt/transaction data
dataToStore := receiptData
if dataToStore == "" {
dataToStore = transactionID
}
if err := s.subscriptionRepo.UpdateReceiptData(userID, dataToStore); err != nil {
return nil, err
}
// Upgrade to Pro (1 year from now - adjust based on actual subscription)
expiresAt := time.Now().UTC().AddDate(1, 0, 0)
// Validate with Apple if client is configured
var expiresAt time.Time
if s.appleClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var result *AppleValidationResult
var err error
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1)
if transactionID != "" {
result, err = s.appleClient.ValidateTransaction(ctx, transactionID)
} else if receiptData != "" {
result, err = s.appleClient.ValidateReceipt(ctx, receiptData)
}
if err != nil {
// Log the validation error
log.Printf("Apple validation warning for user %d: %v", userID, err)
// Check if it's a fatal error
if errors.Is(err, ErrInvalidReceipt) || errors.Is(err, ErrSubscriptionCancelled) {
return nil, err
}
// For other errors (network, etc.), fall back with shorter expiry
expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback
} else if result != nil {
// Use the expiration date from Apple
expiresAt = result.ExpiresAt
log.Printf("Apple purchase validated for user %d: product=%s, expires=%v, env=%s",
userID, result.ProductID, result.ExpiresAt, result.Environment)
}
} else {
// Apple validation not configured - trust client but log warning
log.Printf("Warning: Apple IAP validation not configured, trusting client for user %d", userID)
expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default
}
// Upgrade to Pro with the determined expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return nil, err
}
@@ -300,17 +382,66 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
}
// ProcessGooglePurchase processes a Google Play purchase
func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string) (*SubscriptionResponse, error) {
// TODO: Implement token validation with Google's servers
// For now, just upgrade the user
// Store purchase token
// productID is optional but helps validate the specific subscription
func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
// Store purchase token first
if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil {
return nil, err
}
// Upgrade to Pro (1 year from now - adjust based on actual subscription)
expiresAt := time.Now().UTC().AddDate(1, 0, 0)
// Validate the purchase with Google if client is configured
var expiresAt time.Time
if s.googleClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var result *GoogleValidationResult
var err error
// If productID is provided, use it directly; otherwise try known IDs
if productID != "" {
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken)
} else {
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs)
}
if err != nil {
// Log the validation error
log.Printf("Google purchase validation warning for user %d: %v", userID, err)
// Check if it's a fatal error
if errors.Is(err, ErrInvalidPurchaseToken) || errors.Is(err, ErrSubscriptionCancelled) {
return nil, err
}
if errors.Is(err, ErrSubscriptionExpired) {
// Subscription expired - still allow but set past expiry
expiresAt = time.Now().UTC().Add(-1 * time.Hour)
} else {
// For other errors, fall back with shorter expiry
expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback
}
} else if result != nil {
// Use the expiration date from Google
expiresAt = result.ExpiresAt
log.Printf("Google purchase validated for user %d: product=%s, expires=%v, autoRenew=%v",
userID, result.ProductID, result.ExpiresAt, result.AutoRenewing)
// Acknowledge the subscription if not already acknowledged
if !result.AcknowledgedState {
if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil {
log.Printf("Warning: Failed to acknowledge subscription for user %d: %v", userID, err)
// Don't fail the purchase, just log the warning
}
}
}
} else {
// Google validation not configured - trust client but log warning
log.Printf("Warning: Google IAP validation not configured, trusting client for user %d", userID)
expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default
}
// Upgrade to Pro with the determined expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
return nil, err
}
@@ -511,7 +642,9 @@ func NewPromotionResponse(p *models.Promotion) *PromotionResponse {
// ProcessPurchaseRequest represents an IAP purchase request
type ProcessPurchaseRequest struct {
ReceiptData string `json:"receipt_data"` // iOS
ReceiptData string `json:"receipt_data"` // iOS (StoreKit 1 receipt or StoreKit 2 JWS)
TransactionID string `json:"transaction_id"` // iOS StoreKit 2 transaction ID
PurchaseToken string `json:"purchase_token"` // Android
ProductID string `json:"product_id"` // Android (optional, helps identify subscription)
Platform string `json:"platform" binding:"required,oneof=ios android"`
}