From 7a4253edf4bf762d3017f1c773255f728cf54951 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 26 May 2026 04:18:24 -0700 Subject: [PATCH] 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 --- docs/docs.go | 12 ++- docs/swagger.json | 12 ++- docs/swagger.yaml | 6 ++ internal/domain/payment.go | 31 +++++- internal/domain/payment_test.go | 36 +++++++ internal/services/arifpay/service.go | 12 ++- internal/services/chapa/service.go | 16 +++- internal/web_server/handlers/arifpay.go | 94 ++++++++++++++++--- internal/web_server/handlers/subscriptions.go | 35 +++---- 9 files changed, 207 insertions(+), 47 deletions(-) create mode 100644 internal/domain/payment_test.go 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(),