diff --git a/docs/docs.go b/docs/docs.go index 3d2c012..d6c01f7 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -14284,7 +14284,8 @@ const docTemplate = `{ "required": [ "email", "phone", - "plan_id" + "plan_id", + "provider" ], "properties": { "email": { @@ -14295,6 +14296,9 @@ const docTemplate = `{ }, "plan_id": { "type": "integer" + }, + "provider": { + "type": "string" } } }, @@ -14491,7 +14495,8 @@ const docTemplate = `{ "required": [ "email", "phone", - "plan_id" + "plan_id", + "provider" ], "properties": { "email": { @@ -14502,6 +14507,9 @@ const docTemplate = `{ }, "plan_id": { "type": "integer" + }, + "provider": { + "type": "string" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 4b1ca32..fb398c7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -14276,7 +14276,8 @@ "required": [ "email", "phone", - "plan_id" + "plan_id", + "provider" ], "properties": { "email": { @@ -14287,6 +14288,9 @@ }, "plan_id": { "type": "integer" + }, + "provider": { + "type": "string" } } }, @@ -14483,7 +14487,8 @@ "required": [ "email", "phone", - "plan_id" + "plan_id", + "provider" ], "properties": { "email": { @@ -14494,6 +14499,9 @@ }, "plan_id": { "type": "integer" + }, + "provider": { + "type": "string" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 947bbf7..b7d9056 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2051,10 +2051,13 @@ definitions: type: string plan_id: type: integer + provider: + type: string required: - email - phone - plan_id + - provider type: object handlers.issueListRes: properties: @@ -2189,10 +2192,13 @@ definitions: type: string plan_id: type: integer + provider: + type: string required: - email - phone - plan_id + - provider type: object handlers.teamMemberLoginRes: properties: diff --git a/internal/domain/payment.go b/internal/domain/payment.go index aaef1e8..a6f3f04 100644 --- a/internal/domain/payment.go +++ b/internal/domain/payment.go @@ -1,6 +1,10 @@ package domain -import "time" +import ( + "fmt" + "strings" + "time" +) type PaymentStatus string @@ -43,10 +47,29 @@ type CreatePaymentInput struct { ExpiresAt *time.Time } +type PaymentProvider string + +const ( + PaymentProviderChapa PaymentProvider = "CHAPA" + PaymentProviderArifPay PaymentProvider = "ARIFPAY" +) + +func ParsePaymentProvider(raw string) (PaymentProvider, error) { + switch strings.ToUpper(strings.TrimSpace(raw)) { + case string(PaymentProviderChapa): + return PaymentProviderChapa, nil + case string(PaymentProviderArifPay), "ARIF_PAY": + return PaymentProviderArifPay, nil + default: + return "", fmt.Errorf("unsupported payment provider %q", raw) + } +} + type InitiateSubscriptionPaymentRequest struct { - PlanID int64 `json:"plan_id" validate:"required"` - Phone string `json:"phone" validate:"required"` - Email string `json:"email" validate:"required,email"` + PlanID int64 `json:"plan_id" validate:"required"` + Phone string `json:"phone" validate:"required"` + Email string `json:"email" validate:"required,email"` + Provider PaymentProvider `json:"provider"` } type InitiateSubscriptionPaymentResponse struct { diff --git a/internal/domain/payment_test.go b/internal/domain/payment_test.go new file mode 100644 index 0000000..96c7a85 --- /dev/null +++ b/internal/domain/payment_test.go @@ -0,0 +1,36 @@ +package domain + +import "testing" + +func TestParsePaymentProvider(t *testing.T) { + tests := []struct { + name string + input string + want PaymentProvider + wantErr bool + }{ + {name: "chapa uppercase", input: "CHAPA", want: PaymentProviderChapa}, + {name: "chapa lowercase", input: "chapa", want: PaymentProviderChapa}, + {name: "arifpay uppercase", input: "ARIFPAY", want: PaymentProviderArifPay}, + {name: "arifpay underscored", input: "arif_pay", want: PaymentProviderArifPay}, + {name: "invalid", input: "telebirr", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePaymentProvider(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("provider=%q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/services/arifpay/service.go b/internal/services/arifpay/service.go index 56664b7..a4c308b 100644 --- a/internal/services/arifpay/service.go +++ b/internal/services/arifpay/service.go @@ -73,10 +73,14 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID // Create payment record payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ - UserID: userID, - PlanID: &req.PlanID, - Amount: plan.Price, - Currency: plan.Currency, + UserID: userID, + PlanID: &req.PlanID, + Amount: plan.Price, + Currency: plan.Currency, + PaymentMethod: func() *string { + v := string(domain.PaymentProviderArifPay) + return &v + }(), Nonce: nonce, ExpiresAt: &expiresAt, }) diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 75ca972..1674a4d 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -115,10 +115,14 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64, expiresAt := time.Now().Add(3 * time.Hour) payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ - UserID: userID, - PlanID: &req.PlanID, - Amount: plan.Price, - Currency: plan.Currency, + UserID: userID, + PlanID: &req.PlanID, + Amount: plan.Price, + Currency: plan.Currency, + PaymentMethod: func() *string { + v := string(domain.PaymentProviderChapa) + return &v + }(), Nonce: txRef, ExpiresAt: &expiresAt, }) @@ -279,6 +283,10 @@ func (s *Service) VerifyPayment(ctx context.Context, txRef string) (*domain.Paym return s.lookupPayment(ctx, txRef) } +func (s *Service) LookupPayment(ctx context.Context, ref string) (*domain.Payment, error) { + return s.lookupPayment(ctx, ref) +} + func (s *Service) fetchVerifiedTransaction(ctx context.Context, txRef string) (domain.ChapaTransactionData, error) { url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/internal/web_server/handlers/arifpay.go b/internal/web_server/handlers/arifpay.go index b428d51..b5ec974 100644 --- a/internal/web_server/handlers/arifpay.go +++ b/internal/web_server/handlers/arifpay.go @@ -1,7 +1,9 @@ package handlers import ( + "context" "errors" + "fmt" "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/services/chapa" @@ -15,9 +17,10 @@ import ( // ===================== type initiatePaymentReq struct { - PlanID int64 `json:"plan_id" validate:"required"` - Phone string `json:"phone" validate:"required"` - Email string `json:"email" validate:"required,email"` + 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 paymentRes struct { @@ -68,18 +71,22 @@ func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error { }) } - result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ - PlanID: req.PlanID, - Phone: req.Phone, - Email: req.Email, + 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 := fiber.StatusInternalServerError - if errors.Is(err, chapa.ErrChapaNotConfigured) { - status = fiber.StatusServiceUnavailable - } else if err.Error() == "user already has an active subscription" { - status = fiber.StatusConflict - } + status := paymentInitiationStatus(err) return c.Status(status).JSON(domain.ErrorResponse{ Message: "Failed to initiate payment", Error: err.Error(), @@ -110,7 +117,7 @@ func (h *Handler) VerifyPayment(c *fiber.Ctx) error { }) } - payment, err := h.chapaSvc.VerifyPayment(c.Context(), sessionID) + payment, err := h.verifyPaymentByProvider(c.Context(), sessionID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Payment not found or verification failed", @@ -246,6 +253,65 @@ func (h *Handler) CancelPayment(c *fiber.Ctx) error { }) } +func (h *Handler) initiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) { + switch req.Provider { + case domain.PaymentProviderChapa: + return h.chapaSvc.InitiateSubscriptionPayment(ctx, userID, req) + case domain.PaymentProviderArifPay: + return h.arifpaySvc.InitiateSubscriptionPayment(ctx, userID, req) + default: + return nil, fmt.Errorf("unsupported payment provider %q", req.Provider) + } +} + +func (h *Handler) verifyPaymentByProvider(ctx context.Context, ref string) (*domain.Payment, error) { + payment, err := h.chapaSvc.LookupPayment(ctx, ref) + if err != nil { + return nil, err + } + + if payment.Status == string(domain.PaymentStatusSuccess) || + payment.Status == string(domain.PaymentStatusFailed) || + payment.Status == string(domain.PaymentStatusCancelled) || + payment.Status == string(domain.PaymentStatusExpired) { + return payment, nil + } + + if payment.PaymentMethod != nil { + if provider, err := domain.ParsePaymentProvider(*payment.PaymentMethod); err == nil { + switch provider { + case domain.PaymentProviderChapa: + return h.chapaSvc.VerifyPayment(ctx, ref) + case domain.PaymentProviderArifPay: + return h.arifpaySvc.VerifyPayment(ctx, ref) + } + } + } + + chapaPayment, chapaErr := h.chapaSvc.VerifyPayment(ctx, ref) + if chapaErr == nil { + return chapaPayment, nil + } + arifpayPayment, arifpayErr := h.arifpaySvc.VerifyPayment(ctx, ref) + if arifpayErr == nil { + return arifpayPayment, nil + } + return nil, fmt.Errorf("chapa verify failed: %v; arifpay verify failed: %v", chapaErr, arifpayErr) +} + +func paymentInitiationStatus(err error) int { + switch { + case errors.Is(err, chapa.ErrChapaNotConfigured): + return fiber.StatusServiceUnavailable + case err.Error() == "user already has an active subscription": + return fiber.StatusConflict + case err.Error() == "subscription plan is not active": + return fiber.StatusBadRequest + default: + return fiber.StatusInternalServerError + } +} + // HandleArifpayWebhook godoc // @Summary Handle ArifPay webhook // @Description Processes payment notifications from ArifPay diff --git a/internal/web_server/handlers/subscriptions.go b/internal/web_server/handlers/subscriptions.go index 8bb79e0..c7d14ce 100644 --- a/internal/web_server/handlers/subscriptions.go +++ b/internal/web_server/handlers/subscriptions.go @@ -2,7 +2,6 @@ package handlers import ( "Yimaru-Backend/internal/domain" - "Yimaru-Backend/internal/services/chapa" subscriptionsvc "Yimaru-Backend/internal/services/subscriptions" "context" "encoding/json" @@ -60,9 +59,10 @@ type subscribeReq struct { } type subscribeWithPaymentReq struct { - PlanID int64 `json:"plan_id" validate:"required"` - Phone string `json:"phone" validate:"required"` - Email string `json:"email" validate:"required,email"` + 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 { @@ -381,21 +381,22 @@ func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error { }) } - // Use ArifPay service to initiate payment - result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ - PlanID: req.PlanID, - Phone: req.Phone, - Email: req.Email, + 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 := fiber.StatusInternalServerError - if errors.Is(err, chapa.ErrChapaNotConfigured) { - status = fiber.StatusServiceUnavailable - } else if err.Error() == "user already has an active subscription" { - status = fiber.StatusConflict - } else if err.Error() == "subscription plan is not active" { - status = fiber.StatusBadRequest - } + status := paymentInitiationStatus(err) return c.Status(status).JSON(domain.ErrorResponse{ Message: "Failed to initiate subscription payment", Error: err.Error(),