Yimaru-BackEnd/internal/web_server/handlers/subscriptions.go
Yared Yemane 7a4253edf4 Add explicit payment provider selection for subscriptions.
Require the client to choose CHAPA or ARIFPAY in the subscription checkout request body and route payment initiation and verification through the matching provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 04:18:24 -07:00

654 lines
21 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
"context"
"encoding/json"
"errors"
"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"`
Provider string `json:"provider" validate:"required"`
}
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(),
})
}
provider, err := domain.ParsePaymentProvider(req.Provider)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid payment provider",
Error: err.Error(),
})
}
result, err := h.initiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
PlanID: req.PlanID,
Phone: req.Phone,
Email: req.Email,
Provider: provider,
})
if err != nil {
status := paymentInitiationStatus(err)
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 {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
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.CancelSubscriptionForUser(c.Context(), id, userID)
if err != nil {
switch {
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Subscription not found",
Error: err.Error(),
})
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotOwned):
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You do not have access to this subscription",
Error: err.Error(),
})
default:
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 {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
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.SetAutoRenewForUser(c.Context(), id, userID, req.AutoRenew)
if err != nil {
switch {
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Subscription not found",
Error: err.Error(),
})
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotOwned):
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You do not have access to this subscription",
Error: err.Error(),
})
default:
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"),
}
}