- Update Go module from mycrib-api to casera-api - Update all import statements across 69 Go files - Update admin panel branding (title, sidebar, login form) - Update email templates (subjects, bodies, signatures) - Update PDF report generation branding - Update Docker container names and network - Update config defaults (database name, email sender, APNS topic) - Update README and documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
506 lines
15 KiB
Go
506 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
"github.com/treytartt/casera-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
|
|
}
|
|
|
|
// Get all tier limits and build a map
|
|
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
limitsMap := make(map[string]*TierLimitsClientResponse)
|
|
for _, l := range allLimits {
|
|
limitsMap[string(l.Tier)] = NewTierLimitsClientResponse(&l)
|
|
}
|
|
|
|
// Ensure both free and pro exist with defaults if missing
|
|
if _, ok := limitsMap["free"]; !ok {
|
|
defaults := models.GetDefaultFreeLimits()
|
|
limitsMap["free"] = NewTierLimitsClientResponse(&defaults)
|
|
}
|
|
if _, ok := limitsMap["pro"]; !ok {
|
|
defaults := models.GetDefaultProLimits()
|
|
limitsMap["pro"] = NewTierLimitsClientResponse(&defaults)
|
|
}
|
|
|
|
// Get current usage
|
|
usage, err := s.getUserUsage(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build flattened response (KMM expects subscription fields at top level)
|
|
resp := &SubscriptionStatusResponse{
|
|
AutoRenew: sub.AutoRenew,
|
|
Limits: limitsMap,
|
|
Usage: usage,
|
|
LimitationsEnabled: settings.EnableLimitations,
|
|
}
|
|
|
|
// Format dates if present
|
|
if sub.SubscribedAt != nil {
|
|
t := sub.SubscribedAt.Format("2006-01-02T15:04:05Z")
|
|
resp.SubscribedAt = &t
|
|
}
|
|
if sub.ExpiresAt != nil {
|
|
t := sub.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
|
resp.ExpiresAt = &t
|
|
}
|
|
|
|
return resp, 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{
|
|
PropertiesCount: propertiesCount,
|
|
TasksCount: tasksCount,
|
|
ContractorsCount: contractorsCount,
|
|
DocumentsCount: 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.PropertiesCount >= int64(*limits.PropertiesLimit) {
|
|
return ErrPropertiesLimitExceeded
|
|
}
|
|
case "tasks":
|
|
if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
|
|
return ErrTasksLimitExceeded
|
|
}
|
|
case "contractors":
|
|
if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
|
|
return ErrContractorsLimitExceeded
|
|
}
|
|
case "documents":
|
|
if limits.DocumentsLimit != nil && usage.DocumentsCount >= 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
|
|
}
|
|
|
|
// GetAllUpgradeTriggers gets all active upgrade triggers as a map keyed by trigger_key
|
|
// KMM client expects Map<String, UpgradeTriggerData>
|
|
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
|
|
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[string]*UpgradeTriggerDataResponse)
|
|
for _, t := range triggers {
|
|
result[t.TriggerKey] = NewUpgradeTriggerDataResponse(&t)
|
|
}
|
|
return result, 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 (KMM client expects _count suffix)
|
|
type UsageResponse struct {
|
|
PropertiesCount int64 `json:"properties_count"`
|
|
TasksCount int64 `json:"tasks_count"`
|
|
ContractorsCount int64 `json:"contractors_count"`
|
|
DocumentsCount int64 `json:"documents_count"`
|
|
}
|
|
|
|
// TierLimitsClientResponse represents tier limits for mobile client (simple field names)
|
|
type TierLimitsClientResponse struct {
|
|
Properties *int `json:"properties"`
|
|
Tasks *int `json:"tasks"`
|
|
Contractors *int `json:"contractors"`
|
|
Documents *int `json:"documents"`
|
|
}
|
|
|
|
// NewTierLimitsClientResponse creates a TierLimitsClientResponse from a model
|
|
func NewTierLimitsClientResponse(l *models.TierLimits) *TierLimitsClientResponse {
|
|
return &TierLimitsClientResponse{
|
|
Properties: l.PropertiesLimit,
|
|
Tasks: l.TasksLimit,
|
|
Contractors: l.ContractorsLimit,
|
|
Documents: l.DocumentsLimit,
|
|
}
|
|
}
|
|
|
|
// SubscriptionStatusResponse represents full subscription status
|
|
// Fields are flattened to match KMM client expectations
|
|
type SubscriptionStatusResponse struct {
|
|
// Flattened subscription fields (KMM expects these at top level)
|
|
SubscribedAt *string `json:"subscribed_at"`
|
|
ExpiresAt *string `json:"expires_at"`
|
|
AutoRenew bool `json:"auto_renew"`
|
|
|
|
// Other fields
|
|
Usage *UsageResponse `json:"usage"`
|
|
Limits map[string]*TierLimitsClientResponse `json:"limits"`
|
|
LimitationsEnabled bool `json:"limitations_enabled"`
|
|
}
|
|
|
|
// UpgradeTriggerResponse represents an upgrade trigger (includes trigger_key)
|
|
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,
|
|
}
|
|
}
|
|
|
|
// UpgradeTriggerDataResponse represents trigger data for map values (no trigger_key)
|
|
// Matches KMM UpgradeTriggerData model
|
|
type UpgradeTriggerDataResponse struct {
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
PromoHTML *string `json:"promo_html"`
|
|
ButtonText string `json:"button_text"`
|
|
}
|
|
|
|
// NewUpgradeTriggerDataResponse creates an UpgradeTriggerDataResponse from a model
|
|
func NewUpgradeTriggerDataResponse(t *models.UpgradeTrigger) *UpgradeTriggerDataResponse {
|
|
var promoHTML *string
|
|
if t.PromoHTML != "" {
|
|
promoHTML = &t.PromoHTML
|
|
}
|
|
return &UpgradeTriggerDataResponse{
|
|
Title: t.Title,
|
|
Message: t.Message,
|
|
PromoHTML: 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"`
|
|
}
|