Yimaru-BackEnd/internal/web_server/handlers/subscriptions.go

586 lines
18 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
"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(),
})
}
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(),
})
}
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(),
})
}
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"),
}
}