package handlers import ( "Yimaru-Backend/internal/domain" "context" "encoding/json" "fmt" "strconv" "github.com/gofiber/fiber/v2" ) // ===================== // Subscription Plan Types // ===================== type createPlanReq struct { Name string `json:"name" validate:"required"` Description *string `json:"description"` DurationValue int32 `json:"duration_value" validate:"required,min=1"` DurationUnit string `json:"duration_unit" validate:"required,oneof=DAY WEEK MONTH YEAR"` Price float64 `json:"price" validate:"required,min=0"` Currency string `json:"currency" validate:"required"` IsActive *bool `json:"is_active"` } type updatePlanReq struct { Name *string `json:"name"` Description *string `json:"description"` DurationValue *int32 `json:"duration_value"` DurationUnit *string `json:"duration_unit"` Price *float64 `json:"price"` Currency *string `json:"currency"` IsActive *bool `json:"is_active"` } type planRes struct { ID int64 `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` DurationValue int32 `json:"duration_value"` DurationUnit string `json:"duration_unit"` Price float64 `json:"price"` Currency string `json:"currency"` IsActive bool `json:"is_active"` CreatedAt string `json:"created_at"` } // ===================== // User Subscription Types // ===================== type subscribeReq struct { PlanID int64 `json:"plan_id" validate:"required"` PaymentReference *string `json:"payment_reference"` PaymentMethod *string `json:"payment_method"` } type subscribeWithPaymentReq struct { PlanID int64 `json:"plan_id" validate:"required"` Phone string `json:"phone" validate:"required"` Email string `json:"email" validate:"required,email"` } type subscriptionRes struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` PlanID int64 `json:"plan_id"` PlanName *string `json:"plan_name,omitempty"` StartsAt string `json:"starts_at"` ExpiresAt string `json:"expires_at"` Status string `json:"status"` PaymentReference *string `json:"payment_reference,omitempty"` PaymentMethod *string `json:"payment_method,omitempty"` AutoRenew bool `json:"auto_renew"` DurationValue *int32 `json:"duration_value,omitempty"` DurationUnit *string `json:"duration_unit,omitempty"` Price *float64 `json:"price,omitempty"` Currency *string `json:"currency,omitempty"` CreatedAt string `json:"created_at"` } type autoRenewReq struct { AutoRenew bool `json:"auto_renew"` } // ===================== // Subscription Plan Handlers // ===================== // CreateSubscriptionPlan godoc // @Summary Create a subscription plan // @Description Creates a new subscription plan (admin only) // @Tags subscriptions // @Accept json // @Produce json // @Param body body createPlanReq true "Create plan payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscription-plans [post] func (h *Handler) CreateSubscriptionPlan(c *fiber.Ctx) error { var req createPlanReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } plan, err := h.subscriptionsSvc.CreatePlan(c.Context(), domain.CreateSubscriptionPlanInput{ Name: req.Name, Description: req.Description, DurationValue: req.DurationValue, DurationUnit: req.DurationUnit, Price: req.Price, Currency: req.Currency, IsActive: req.IsActive, }) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create subscription plan", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"name": plan.Name, "price": plan.Price}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubscriptionPlanCreated, domain.ResourceSubscriptionPlan, &plan.ID, "Created subscription plan: "+plan.Name, meta, &ip, &ua) return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Subscription plan created successfully", Data: planToRes(plan), }) } // ListSubscriptionPlans godoc // @Summary List subscription plans // @Description Returns all subscription plans // @Tags subscriptions // @Produce json // @Param active_only query bool false "Return only active plans" // @Success 200 {object} domain.Response // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscription-plans [get] func (h *Handler) ListSubscriptionPlans(c *fiber.Ctx) error { activeOnly := c.Query("active_only", "true") == "true" plans, err := h.subscriptionsSvc.ListPlans(c.Context(), activeOnly) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to list subscription plans", Error: err.Error(), }) } result := make([]planRes, len(plans)) for i, p := range plans { result[i] = *planToRes(&p) } return c.JSON(domain.Response{ Message: "Subscription plans retrieved successfully", Data: result, }) } // GetSubscriptionPlan godoc // @Summary Get a subscription plan // @Description Returns a single subscription plan by ID // @Tags subscriptions // @Produce json // @Param id path int true "Plan ID" // @Success 200 {object} domain.Response // @Failure 404 {object} domain.ErrorResponse // @Router /api/v1/subscription-plans/{id} [get] func (h *Handler) GetSubscriptionPlan(c *fiber.Ctx) error { id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid plan ID", }) } plan, err := h.subscriptionsSvc.GetPlanByID(c.Context(), id) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Subscription plan not found", }) } return c.JSON(domain.Response{ Message: "Subscription plan retrieved successfully", Data: planToRes(plan), }) } // UpdateSubscriptionPlan godoc // @Summary Update a subscription plan // @Description Updates a subscription plan (admin only) // @Tags subscriptions // @Accept json // @Produce json // @Param id path int true "Plan ID" // @Param body body updatePlanReq true "Update plan payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscription-plans/{id} [put] func (h *Handler) UpdateSubscriptionPlan(c *fiber.Ctx) error { id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid plan ID", }) } var req updatePlanReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } err = h.subscriptionsSvc.UpdatePlan(c.Context(), id, domain.UpdateSubscriptionPlanInput{ Name: req.Name, Description: req.Description, DurationValue: req.DurationValue, DurationUnit: req.DurationUnit, Price: req.Price, Currency: req.Currency, IsActive: req.IsActive, }) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update subscription plan", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"plan_id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubscriptionPlanUpdated, domain.ResourceSubscriptionPlan, &id, fmt.Sprintf("Updated subscription plan ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Subscription plan updated successfully", }) } // DeleteSubscriptionPlan godoc // @Summary Delete a subscription plan // @Description Deletes a subscription plan (admin only) // @Tags subscriptions // @Produce json // @Param id path int true "Plan ID" // @Success 200 {object} domain.Response // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscription-plans/{id} [delete] func (h *Handler) DeleteSubscriptionPlan(c *fiber.Ctx) error { id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid plan ID", }) } err = h.subscriptionsSvc.DeletePlan(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to delete subscription plan", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"plan_id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubscriptionPlanDeleted, domain.ResourceSubscriptionPlan, &id, fmt.Sprintf("Deleted subscription plan ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Subscription plan deleted successfully", }) } // ===================== // User Subscription Handlers // ===================== // Subscribe godoc // @Summary Subscribe to a plan (Admin only - bypasses payment) // @Description Creates a new subscription for the authenticated user. For regular users, use /payments/subscribe instead. // @Tags subscriptions // @Accept json // @Produce json // @Param body body subscribeReq true "Subscribe payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscriptions [post] // @deprecated Use POST /api/v1/payments/subscribe for user subscriptions with payment func (h *Handler) Subscribe(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").(int64) if !ok { return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ Message: "Unauthorized", }) } // Check role - only admins can create subscriptions without payment role, ok := c.Locals("role").(domain.Role) if !ok || (role != domain.RoleAdmin && role != domain.RoleSuperAdmin) { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "Use /api/v1/payments/subscribe to subscribe with payment", Error: "Direct subscription creation requires admin privileges", }) } var req subscribeReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } sub, err := h.subscriptionsSvc.Subscribe(c.Context(), userID, req.PlanID, req.PaymentReference, req.PaymentMethod) if err != nil { status := fiber.StatusInternalServerError if err.Error() == "user already has an active subscription" { status = fiber.StatusConflict } return c.Status(status).JSON(domain.ErrorResponse{ Message: "Failed to create subscription", Error: err.Error(), }) } return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Subscription created successfully", Data: subscriptionToRes(sub), }) } // SubscribeWithPayment godoc // @Summary Subscribe to a plan with payment // @Description Initiates payment for a subscription plan. Returns payment URL for checkout. // @Tags subscriptions // @Accept json // @Produce json // @Param body body subscribeWithPaymentReq true "Subscribe with payment payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 409 {object} domain.ErrorResponse "User already has active subscription" // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscriptions/checkout [post] func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").(int64) if !ok { return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ Message: "Unauthorized", }) } var req subscribeWithPaymentReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } // Use ArifPay service to initiate payment result, err := h.arifpaySvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ PlanID: req.PlanID, Phone: req.Phone, Email: req.Email, }) if err != nil { status := fiber.StatusInternalServerError if err.Error() == "user already has an active subscription" { status = fiber.StatusConflict } else if err.Error() == "subscription plan is not active" { status = fiber.StatusBadRequest } return c.Status(status).JSON(domain.ErrorResponse{ Message: "Failed to initiate subscription payment", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Payment initiated. Complete payment to activate subscription.", Data: result, }) } // GetMySubscription godoc // @Summary Get current subscription // @Description Returns the authenticated user's active subscription // @Tags subscriptions // @Produce json // @Success 200 {object} domain.Response // @Failure 404 {object} domain.ErrorResponse // @Router /api/v1/subscriptions/me [get] func (h *Handler) GetMySubscription(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").(int64) if !ok { return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ Message: "Unauthorized", }) } sub, err := h.subscriptionsSvc.GetActiveSubscription(c.Context(), userID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "No active subscription found", }) } return c.JSON(domain.Response{ Message: "Subscription retrieved successfully", Data: subscriptionToRes(sub), }) } // GetMySubscriptionHistory godoc // @Summary Get subscription history // @Description Returns the authenticated user's subscription history // @Tags subscriptions // @Produce json // @Param limit query int false "Limit" default(20) // @Param offset query int false "Offset" default(0) // @Success 200 {object} domain.Response // @Router /api/v1/subscriptions/history [get] func (h *Handler) GetMySubscriptionHistory(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").(int64) if !ok { return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ Message: "Unauthorized", }) } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) subs, err := h.subscriptionsSvc.GetSubscriptionHistory(c.Context(), userID, int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to get subscription history", Error: err.Error(), }) } result := make([]subscriptionRes, len(subs)) for i, s := range subs { result[i] = *subscriptionToRes(&s) } return c.JSON(domain.Response{ Message: "Subscription history retrieved successfully", Data: result, }) } // CheckSubscriptionStatus godoc // @Summary Check subscription status // @Description Returns whether the authenticated user has an active subscription // @Tags subscriptions // @Produce json // @Success 200 {object} domain.Response // @Router /api/v1/subscriptions/status [get] func (h *Handler) CheckSubscriptionStatus(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").(int64) if !ok { return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ Message: "Unauthorized", }) } hasActive, err := h.subscriptionsSvc.HasActiveSubscription(c.Context(), userID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to check subscription status", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Subscription status retrieved", Data: fiber.Map{ "has_active_subscription": hasActive, }, }) } // CancelSubscription godoc // @Summary Cancel subscription // @Description Cancels the user's subscription // @Tags subscriptions // @Produce json // @Param id path int true "Subscription ID" // @Success 200 {object} domain.Response // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscriptions/{id}/cancel [post] func (h *Handler) CancelSubscription(c *fiber.Ctx) error { id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid subscription ID", }) } err = h.subscriptionsSvc.CancelSubscription(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to cancel subscription", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Subscription cancelled successfully", }) } // SetAutoRenew godoc // @Summary Set auto-renew // @Description Enables or disables auto-renewal for a subscription // @Tags subscriptions // @Accept json // @Produce json // @Param id path int true "Subscription ID" // @Param body body autoRenewReq true "Auto-renew payload" // @Success 200 {object} domain.Response // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/subscriptions/{id}/auto-renew [put] func (h *Handler) SetAutoRenew(c *fiber.Ctx) error { id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid subscription ID", }) } var req autoRenewReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } err = h.subscriptionsSvc.SetAutoRenew(c.Context(), id, req.AutoRenew) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update auto-renew setting", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Auto-renew setting updated successfully", }) } // Helper functions func planToRes(p *domain.SubscriptionPlan) *planRes { return &planRes{ ID: p.ID, Name: p.Name, Description: p.Description, DurationValue: p.DurationValue, DurationUnit: p.DurationUnit, Price: p.Price, Currency: p.Currency, IsActive: p.IsActive, CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } } func subscriptionToRes(s *domain.UserSubscription) *subscriptionRes { return &subscriptionRes{ ID: s.ID, UserID: s.UserID, PlanID: s.PlanID, PlanName: s.PlanName, StartsAt: s.StartsAt.Format("2006-01-02T15:04:05Z07:00"), ExpiresAt: s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"), Status: s.Status, PaymentReference: s.PaymentReference, PaymentMethod: s.PaymentMethod, AutoRenew: s.AutoRenew, DurationValue: s.DurationValue, DurationUnit: s.DurationUnit, Price: s.Price, Currency: s.Currency, CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } }