Initial commit: MyCrib API in Go
Complete rewrite of Django REST API to Go with: - Gin web framework for HTTP routing - GORM for database operations - GoAdmin for admin panel - Gorush integration for push notifications - Redis for caching and job queues Features implemented: - User authentication (login, register, logout, password reset) - Residence management (CRUD, sharing, share codes) - Task management (CRUD, kanban board, completions) - Contractor management (CRUD, specialties) - Document management (CRUD, warranties) - Notifications (preferences, push notifications) - Subscription management (tiers, limits) Infrastructure: - Docker Compose for local development - Database migrations and seed data - Admin panel for data management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
417
internal/services/subscription_service.go
Normal file
417
internal/services/subscription_service.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Subscription-related errors
|
||||
var (
|
||||
ErrSubscriptionNotFound = errors.New("subscription not found")
|
||||
ErrPropertiesLimitExceeded = errors.New("properties limit exceeded for your subscription tier")
|
||||
ErrTasksLimitExceeded = errors.New("tasks limit exceeded for your subscription tier")
|
||||
ErrContractorsLimitExceeded = errors.New("contractors limit exceeded for your subscription tier")
|
||||
ErrDocumentsLimitExceeded = errors.New("documents limit exceeded for your subscription tier")
|
||||
ErrUpgradeTriggerNotFound = errors.New("upgrade trigger not found")
|
||||
ErrPromotionNotFound = errors.New("promotion not found")
|
||||
)
|
||||
|
||||
// SubscriptionService handles subscription business logic
|
||||
type SubscriptionService struct {
|
||||
subscriptionRepo *repositories.SubscriptionRepository
|
||||
residenceRepo *repositories.ResidenceRepository
|
||||
taskRepo *repositories.TaskRepository
|
||||
contractorRepo *repositories.ContractorRepository
|
||||
documentRepo *repositories.DocumentRepository
|
||||
}
|
||||
|
||||
// NewSubscriptionService creates a new subscription service
|
||||
func NewSubscriptionService(
|
||||
subscriptionRepo *repositories.SubscriptionRepository,
|
||||
residenceRepo *repositories.ResidenceRepository,
|
||||
taskRepo *repositories.TaskRepository,
|
||||
contractorRepo *repositories.ContractorRepository,
|
||||
documentRepo *repositories.DocumentRepository,
|
||||
) *SubscriptionService {
|
||||
return &SubscriptionService{
|
||||
subscriptionRepo: subscriptionRepo,
|
||||
residenceRepo: residenceRepo,
|
||||
taskRepo: taskRepo,
|
||||
contractorRepo: contractorRepo,
|
||||
documentRepo: documentRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubscription gets the subscription for a user
|
||||
func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionResponse, error) {
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSubscriptionResponse(sub), nil
|
||||
}
|
||||
|
||||
// GetSubscriptionStatus gets detailed subscription status including limits
|
||||
func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionStatusResponse, error) {
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings, err := s.subscriptionRepo.GetSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current usage if limitations are enabled
|
||||
var usage *UsageResponse
|
||||
if settings.EnableLimitations {
|
||||
usage, err = s.getUserUsage(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &SubscriptionStatusResponse{
|
||||
Subscription: NewSubscriptionResponse(sub),
|
||||
Limits: NewTierLimitsResponse(limits),
|
||||
Usage: usage,
|
||||
LimitationsEnabled: settings.EnableLimitations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getUserUsage calculates current usage for a user
|
||||
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
|
||||
residences, err := s.residenceRepo.FindOwnedByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
propertiesCount := int64(len(residences))
|
||||
|
||||
// Count tasks, contractors, and documents across all user's residences
|
||||
var tasksCount, contractorsCount, documentsCount int64
|
||||
for _, r := range residences {
|
||||
tc, err := s.taskRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasksCount += tc
|
||||
|
||||
cc, err := s.contractorRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contractorsCount += cc
|
||||
|
||||
dc, err := s.documentRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
documentsCount += dc
|
||||
}
|
||||
|
||||
return &UsageResponse{
|
||||
Properties: propertiesCount,
|
||||
Tasks: tasksCount,
|
||||
Contractors: contractorsCount,
|
||||
Documents: documentsCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckLimit checks if a user has exceeded a specific limit
|
||||
func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
||||
settings, err := s.subscriptionRepo.GetSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If limitations are disabled, allow everything
|
||||
if !settings.EnableLimitations {
|
||||
return nil
|
||||
}
|
||||
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pro users have unlimited access
|
||||
if sub.IsPro() {
|
||||
return nil
|
||||
}
|
||||
|
||||
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usage, err := s.getUserUsage(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch limitType {
|
||||
case "properties":
|
||||
if limits.PropertiesLimit != nil && usage.Properties >= int64(*limits.PropertiesLimit) {
|
||||
return ErrPropertiesLimitExceeded
|
||||
}
|
||||
case "tasks":
|
||||
if limits.TasksLimit != nil && usage.Tasks >= int64(*limits.TasksLimit) {
|
||||
return ErrTasksLimitExceeded
|
||||
}
|
||||
case "contractors":
|
||||
if limits.ContractorsLimit != nil && usage.Contractors >= int64(*limits.ContractorsLimit) {
|
||||
return ErrContractorsLimitExceeded
|
||||
}
|
||||
case "documents":
|
||||
if limits.DocumentsLimit != nil && usage.Documents >= int64(*limits.DocumentsLimit) {
|
||||
return ErrDocumentsLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUpgradeTrigger gets an upgrade trigger by key
|
||||
func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResponse, error) {
|
||||
trigger, err := s.subscriptionRepo.GetUpgradeTrigger(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUpgradeTriggerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return NewUpgradeTriggerResponse(trigger), nil
|
||||
}
|
||||
|
||||
// GetFeatureBenefits gets all feature benefits
|
||||
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
|
||||
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]FeatureBenefitResponse, len(benefits))
|
||||
for i, b := range benefits {
|
||||
result[i] = *NewFeatureBenefitResponse(&b)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetActivePromotions gets active promotions for a user
|
||||
func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionResponse, error) {
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]PromotionResponse, len(promotions))
|
||||
for i, p := range promotions {
|
||||
result[i] = *NewPromotionResponse(&p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Upgrade to Pro (1 year from now - adjust based on actual subscription)
|
||||
expiresAt := time.Now().UTC().AddDate(1, 0, 0)
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetSubscription(userID)
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetSubscription(userID)
|
||||
}
|
||||
|
||||
// CancelSubscription cancels a subscription (downgrades to free at end of period)
|
||||
func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResponse, error) {
|
||||
if err := s.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSubscription(userID)
|
||||
}
|
||||
|
||||
// === Response Types ===
|
||||
|
||||
// SubscriptionResponse represents a subscription in API response
|
||||
type SubscriptionResponse struct {
|
||||
Tier string `json:"tier"`
|
||||
SubscribedAt *string `json:"subscribed_at"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
CancelledAt *string `json:"cancelled_at"`
|
||||
Platform string `json:"platform"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsPro bool `json:"is_pro"`
|
||||
}
|
||||
|
||||
// NewSubscriptionResponse creates a SubscriptionResponse from a model
|
||||
func NewSubscriptionResponse(s *models.UserSubscription) *SubscriptionResponse {
|
||||
resp := &SubscriptionResponse{
|
||||
Tier: string(s.Tier),
|
||||
AutoRenew: s.AutoRenew,
|
||||
Platform: s.Platform,
|
||||
IsActive: s.IsActive(),
|
||||
IsPro: s.IsPro(),
|
||||
}
|
||||
if s.SubscribedAt != nil {
|
||||
t := s.SubscribedAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.SubscribedAt = &t
|
||||
}
|
||||
if s.ExpiresAt != nil {
|
||||
t := s.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.ExpiresAt = &t
|
||||
}
|
||||
if s.CancelledAt != nil {
|
||||
t := s.CancelledAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.CancelledAt = &t
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TierLimitsResponse represents tier limits
|
||||
type TierLimitsResponse struct {
|
||||
Tier string `json:"tier"`
|
||||
PropertiesLimit *int `json:"properties_limit"`
|
||||
TasksLimit *int `json:"tasks_limit"`
|
||||
ContractorsLimit *int `json:"contractors_limit"`
|
||||
DocumentsLimit *int `json:"documents_limit"`
|
||||
}
|
||||
|
||||
// NewTierLimitsResponse creates a TierLimitsResponse from a model
|
||||
func NewTierLimitsResponse(l *models.TierLimits) *TierLimitsResponse {
|
||||
return &TierLimitsResponse{
|
||||
Tier: string(l.Tier),
|
||||
PropertiesLimit: l.PropertiesLimit,
|
||||
TasksLimit: l.TasksLimit,
|
||||
ContractorsLimit: l.ContractorsLimit,
|
||||
DocumentsLimit: l.DocumentsLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// UsageResponse represents current usage
|
||||
type UsageResponse struct {
|
||||
Properties int64 `json:"properties"`
|
||||
Tasks int64 `json:"tasks"`
|
||||
Contractors int64 `json:"contractors"`
|
||||
Documents int64 `json:"documents"`
|
||||
}
|
||||
|
||||
// SubscriptionStatusResponse represents full subscription status
|
||||
type SubscriptionStatusResponse struct {
|
||||
Subscription *SubscriptionResponse `json:"subscription"`
|
||||
Limits *TierLimitsResponse `json:"limits"`
|
||||
Usage *UsageResponse `json:"usage,omitempty"`
|
||||
LimitationsEnabled bool `json:"limitations_enabled"`
|
||||
}
|
||||
|
||||
// UpgradeTriggerResponse represents an upgrade trigger
|
||||
type UpgradeTriggerResponse struct {
|
||||
TriggerKey string `json:"trigger_key"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
PromoHTML string `json:"promo_html"`
|
||||
ButtonText string `json:"button_text"`
|
||||
}
|
||||
|
||||
// NewUpgradeTriggerResponse creates an UpgradeTriggerResponse from a model
|
||||
func NewUpgradeTriggerResponse(t *models.UpgradeTrigger) *UpgradeTriggerResponse {
|
||||
return &UpgradeTriggerResponse{
|
||||
TriggerKey: t.TriggerKey,
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
PromoHTML: t.PromoHTML,
|
||||
ButtonText: t.ButtonText,
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureBenefitResponse represents a feature benefit
|
||||
type FeatureBenefitResponse struct {
|
||||
FeatureName string `json:"feature_name"`
|
||||
FreeTierText string `json:"free_tier_text"`
|
||||
ProTierText string `json:"pro_tier_text"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
// NewFeatureBenefitResponse creates a FeatureBenefitResponse from a model
|
||||
func NewFeatureBenefitResponse(f *models.FeatureBenefit) *FeatureBenefitResponse {
|
||||
return &FeatureBenefitResponse{
|
||||
FeatureName: f.FeatureName,
|
||||
FreeTierText: f.FreeTierText,
|
||||
ProTierText: f.ProTierText,
|
||||
DisplayOrder: f.DisplayOrder,
|
||||
}
|
||||
}
|
||||
|
||||
// PromotionResponse represents a promotion
|
||||
type PromotionResponse struct {
|
||||
PromotionID string `json:"promotion_id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Link *string `json:"link"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
}
|
||||
|
||||
// NewPromotionResponse creates a PromotionResponse from a model
|
||||
func NewPromotionResponse(p *models.Promotion) *PromotionResponse {
|
||||
return &PromotionResponse{
|
||||
PromotionID: p.PromotionID,
|
||||
Title: p.Title,
|
||||
Message: p.Message,
|
||||
Link: p.Link,
|
||||
StartDate: p.StartDate.Format("2006-01-02"),
|
||||
EndDate: p.EndDate.Format("2006-01-02"),
|
||||
}
|
||||
}
|
||||
|
||||
// === Request Types ===
|
||||
|
||||
// ProcessPurchaseRequest represents an IAP purchase request
|
||||
type ProcessPurchaseRequest struct {
|
||||
ReceiptData string `json:"receipt_data"` // iOS
|
||||
PurchaseToken string `json:"purchase_token"` // Android
|
||||
Platform string `json:"platform" binding:"required,oneof=ios android"`
|
||||
}
|
||||
Reference in New Issue
Block a user