Yimaru-BackEnd/internal/services/subscriptions/service.go
Yared Yemane 49bcc22d0d Expose subscription_status on user profile responses instead of active_subscription.
Users see ACTIVE, PENDING, or Unsubscribed via new batch and single SQL helpers; Swagger refreshed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:28:19 -07:00

202 lines
6.3 KiB
Go

package subscriptions
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var (
ErrPlanNotFound = errors.New("subscription plan not found")
ErrSubscriptionNotFound = errors.New("subscription not found")
ErrSubscriptionNotOwned = errors.New("subscription does not belong to this user")
ErrAlreadySubscribed = errors.New("user already has an active subscription")
ErrInvalidPlan = errors.New("invalid subscription plan")
)
type Service struct {
store ports.SubscriptionStore
}
func NewService(store ports.SubscriptionStore) *Service {
return &Service{store: store}
}
// =====================
// Subscription Plans
// =====================
func (s *Service) CreatePlan(ctx context.Context, input domain.CreateSubscriptionPlanInput) (*domain.SubscriptionPlan, error) {
return s.store.CreateSubscriptionPlan(ctx, input)
}
func (s *Service) GetPlanByID(ctx context.Context, id int64) (*domain.SubscriptionPlan, error) {
return s.store.GetSubscriptionPlanByID(ctx, id)
}
func (s *Service) ListPlans(ctx context.Context, activeOnly bool) ([]domain.SubscriptionPlan, error) {
return s.store.ListSubscriptionPlans(ctx, activeOnly)
}
func (s *Service) UpdatePlan(ctx context.Context, id int64, input domain.UpdateSubscriptionPlanInput) error {
return s.store.UpdateSubscriptionPlan(ctx, id, input)
}
func (s *Service) DeletePlan(ctx context.Context, id int64) error {
return s.store.DeleteSubscriptionPlan(ctx, id)
}
// =====================
// User Subscriptions
// =====================
// Subscribe creates a new subscription for a user
func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRef, paymentMethod *string) (*domain.UserSubscription, error) {
// Check if user already has an active subscription
hasActive, err := s.store.HasActiveSubscription(ctx, userID)
if err != nil {
return nil, err
}
if hasActive {
return nil, ErrAlreadySubscribed
}
// Get the plan to calculate expiry
plan, err := s.store.GetSubscriptionPlanByID(ctx, planID)
if err != nil {
return nil, ErrPlanNotFound
}
if !plan.IsActive {
return nil, ErrInvalidPlan
}
// Calculate expiry date
startsAt := time.Now()
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
input := domain.CreateUserSubscriptionInput{
UserID: userID,
PlanID: planID,
StartsAt: &startsAt,
ExpiresAt: expiresAt,
Status: strPtr(string(domain.SubscriptionStatusActive)),
PaymentReference: paymentRef,
PaymentMethod: paymentMethod,
AutoRenew: boolPtr(false),
}
return s.store.CreateUserSubscription(ctx, input)
}
func (s *Service) GetSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) {
sub, err := s.store.GetUserSubscriptionByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSubscriptionNotFound
}
return nil, err
}
return sub, nil
}
// GetActiveSubscription returns the ACTIVE, non-expired subscription for the user.
func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*domain.UserSubscription, error) {
return s.store.GetActiveSubscriptionByUserID(ctx, userID)
}
// ListSubscriptionDisplayStatusesForUserIDs returns ACTIVE, PENDING, or Unsubscribed per user_id (admin list).
func (s *Service) ListSubscriptionDisplayStatusesForUserIDs(ctx context.Context, userIDs []int64) (map[int64]string, error) {
return s.store.ListSubscriptionDisplayStatusesByUserIDs(ctx, userIDs)
}
// GetSubscriptionDisplayStatusForUserID returns ACTIVE, PENDING, or Unsubscribed for one user.
func (s *Service) GetSubscriptionDisplayStatusForUserID(ctx context.Context, userID int64) (string, error) {
return s.store.GetSubscriptionDisplayStatusByUserID(ctx, userID)
}
func (s *Service) GetSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
return s.store.GetUserSubscriptionHistory(ctx, userID, limit, offset)
}
func (s *Service) HasActiveSubscription(ctx context.Context, userID int64) (bool, error) {
return s.store.HasActiveSubscription(ctx, userID)
}
func (s *Service) subscriptionOwnedBy(ctx context.Context, subscriptionID, userID int64) error {
sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrSubscriptionNotFound
}
return err
}
if sub.UserID != userID {
return ErrSubscriptionNotOwned
}
return nil
}
// CancelSubscriptionForUser cancels only if the subscription row belongs to userID.
func (s *Service) CancelSubscriptionForUser(ctx context.Context, subscriptionID, userID int64) error {
if err := s.subscriptionOwnedBy(ctx, subscriptionID, userID); err != nil {
return err
}
return s.store.CancelUserSubscription(ctx, subscriptionID)
}
// SetAutoRenewForUser updates auto-renew only if the subscription belongs to userID.
func (s *Service) SetAutoRenewForUser(ctx context.Context, subscriptionID, userID int64, autoRenew bool) error {
if err := s.subscriptionOwnedBy(ctx, subscriptionID, userID); err != nil {
return err
}
return s.store.UpdateAutoRenew(ctx, subscriptionID, autoRenew)
}
// RenewSubscription extends an existing subscription
func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) (*domain.UserSubscription, error) {
sub, err := s.GetSubscriptionByID(ctx, subscriptionID)
if err != nil {
return nil, err
}
plan, err := s.store.GetSubscriptionPlanByID(ctx, sub.PlanID)
if err != nil {
return nil, ErrPlanNotFound
}
// Calculate new expiry from current expiry (or now if expired)
baseTime := sub.ExpiresAt
if baseTime.Before(time.Now()) {
baseTime = time.Now()
}
newExpiry := domain.CalculateExpiryDate(baseTime, plan.DurationValue, plan.DurationUnit)
err = s.store.ExtendSubscription(ctx, subscriptionID, newExpiry)
if err != nil {
return nil, err
}
// Also reactivate if expired
if sub.Status == string(domain.SubscriptionStatusExpired) {
err = s.store.UpdateSubscriptionStatus(ctx, subscriptionID, string(domain.SubscriptionStatusActive))
if err != nil {
return nil, err
}
}
return s.GetSubscriptionByID(ctx, subscriptionID)
}
// Helper functions
func strPtr(s string) *string {
return &s
}
func boolPtr(b bool) *bool {
return &b
}