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>
This commit is contained in:
Yared Yemane 2026-05-26 04:18:24 -07:00
parent 82de00b1e7
commit 7a4253edf4
9 changed files with 207 additions and 47 deletions

View File

@ -14284,7 +14284,8 @@ const docTemplate = `{
"required": [ "required": [
"email", "email",
"phone", "phone",
"plan_id" "plan_id",
"provider"
], ],
"properties": { "properties": {
"email": { "email": {
@ -14295,6 +14296,9 @@ const docTemplate = `{
}, },
"plan_id": { "plan_id": {
"type": "integer" "type": "integer"
},
"provider": {
"type": "string"
} }
} }
}, },
@ -14491,7 +14495,8 @@ const docTemplate = `{
"required": [ "required": [
"email", "email",
"phone", "phone",
"plan_id" "plan_id",
"provider"
], ],
"properties": { "properties": {
"email": { "email": {
@ -14502,6 +14507,9 @@ const docTemplate = `{
}, },
"plan_id": { "plan_id": {
"type": "integer" "type": "integer"
},
"provider": {
"type": "string"
} }
} }
}, },

View File

@ -14276,7 +14276,8 @@
"required": [ "required": [
"email", "email",
"phone", "phone",
"plan_id" "plan_id",
"provider"
], ],
"properties": { "properties": {
"email": { "email": {
@ -14287,6 +14288,9 @@
}, },
"plan_id": { "plan_id": {
"type": "integer" "type": "integer"
},
"provider": {
"type": "string"
} }
} }
}, },
@ -14483,7 +14487,8 @@
"required": [ "required": [
"email", "email",
"phone", "phone",
"plan_id" "plan_id",
"provider"
], ],
"properties": { "properties": {
"email": { "email": {
@ -14494,6 +14499,9 @@
}, },
"plan_id": { "plan_id": {
"type": "integer" "type": "integer"
},
"provider": {
"type": "string"
} }
} }
}, },

View File

@ -2051,10 +2051,13 @@ definitions:
type: string type: string
plan_id: plan_id:
type: integer type: integer
provider:
type: string
required: required:
- email - email
- phone - phone
- plan_id - plan_id
- provider
type: object type: object
handlers.issueListRes: handlers.issueListRes:
properties: properties:
@ -2189,10 +2192,13 @@ definitions:
type: string type: string
plan_id: plan_id:
type: integer type: integer
provider:
type: string
required: required:
- email - email
- phone - phone
- plan_id - plan_id
- provider
type: object type: object
handlers.teamMemberLoginRes: handlers.teamMemberLoginRes:
properties: properties:

View File

@ -1,6 +1,10 @@
package domain package domain
import "time" import (
"fmt"
"strings"
"time"
)
type PaymentStatus string type PaymentStatus string
@ -43,10 +47,29 @@ type CreatePaymentInput struct {
ExpiresAt *time.Time 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 { type InitiateSubscriptionPaymentRequest struct {
PlanID int64 `json:"plan_id" validate:"required"` PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"` Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Provider PaymentProvider `json:"provider"`
} }
type InitiateSubscriptionPaymentResponse struct { type InitiateSubscriptionPaymentResponse struct {

View File

@ -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)
}
})
}
}

View File

@ -73,10 +73,14 @@ func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID
// Create payment record // Create payment record
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
UserID: userID, UserID: userID,
PlanID: &req.PlanID, PlanID: &req.PlanID,
Amount: plan.Price, Amount: plan.Price,
Currency: plan.Currency, Currency: plan.Currency,
PaymentMethod: func() *string {
v := string(domain.PaymentProviderArifPay)
return &v
}(),
Nonce: nonce, Nonce: nonce,
ExpiresAt: &expiresAt, ExpiresAt: &expiresAt,
}) })

View File

@ -115,10 +115,14 @@ func (s *Service) InitiateSubscriptionPayment(ctx context.Context, userID int64,
expiresAt := time.Now().Add(3 * time.Hour) expiresAt := time.Now().Add(3 * time.Hour)
payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{ payment, err := s.paymentStore.CreatePayment(ctx, domain.CreatePaymentInput{
UserID: userID, UserID: userID,
PlanID: &req.PlanID, PlanID: &req.PlanID,
Amount: plan.Price, Amount: plan.Price,
Currency: plan.Currency, Currency: plan.Currency,
PaymentMethod: func() *string {
v := string(domain.PaymentProviderChapa)
return &v
}(),
Nonce: txRef, Nonce: txRef,
ExpiresAt: &expiresAt, ExpiresAt: &expiresAt,
}) })
@ -279,6 +283,10 @@ func (s *Service) VerifyPayment(ctx context.Context, txRef string) (*domain.Paym
return s.lookupPayment(ctx, txRef) 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) { func (s *Service) fetchVerifiedTransaction(ctx context.Context, txRef string) (domain.ChapaTransactionData, error) {
url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef url := strings.TrimSuffix(s.cfg.CHAPA_BASE_URL, "/") + "/transaction/verify/" + txRef
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

View File

@ -1,7 +1,9 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"fmt"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa" "Yimaru-Backend/internal/services/chapa"
@ -15,9 +17,10 @@ import (
// ===================== // =====================
type initiatePaymentReq struct { type initiatePaymentReq struct {
PlanID int64 `json:"plan_id" validate:"required"` PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"` Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Provider string `json:"provider" validate:"required"`
} }
type paymentRes struct { 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{ provider, err := domain.ParsePaymentProvider(req.Provider)
PlanID: req.PlanID, if err != nil {
Phone: req.Phone, return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Email: req.Email, 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 { if err != nil {
status := fiber.StatusInternalServerError status := paymentInitiationStatus(err)
if errors.Is(err, chapa.ErrChapaNotConfigured) {
status = fiber.StatusServiceUnavailable
} else if err.Error() == "user already has an active subscription" {
status = fiber.StatusConflict
}
return c.Status(status).JSON(domain.ErrorResponse{ return c.Status(status).JSON(domain.ErrorResponse{
Message: "Failed to initiate payment", Message: "Failed to initiate payment",
Error: err.Error(), 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 { if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Payment not found or verification failed", 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 // HandleArifpayWebhook godoc
// @Summary Handle ArifPay webhook // @Summary Handle ArifPay webhook
// @Description Processes payment notifications from ArifPay // @Description Processes payment notifications from ArifPay

View File

@ -2,7 +2,6 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa"
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions" subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
"context" "context"
"encoding/json" "encoding/json"
@ -60,9 +59,10 @@ type subscribeReq struct {
} }
type subscribeWithPaymentReq struct { type subscribeWithPaymentReq struct {
PlanID int64 `json:"plan_id" validate:"required"` PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"` Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Provider string `json:"provider" validate:"required"`
} }
type subscriptionRes struct { type subscriptionRes struct {
@ -381,21 +381,22 @@ func (h *Handler) SubscribeWithPayment(c *fiber.Ctx) error {
}) })
} }
// Use ArifPay service to initiate payment provider, err := domain.ParsePaymentProvider(req.Provider)
result, err := h.chapaSvc.InitiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{ if err != nil {
PlanID: req.PlanID, return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Phone: req.Phone, Message: "Invalid payment provider",
Email: req.Email, 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 { if err != nil {
status := fiber.StatusInternalServerError status := paymentInitiationStatus(err)
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
}
return c.Status(status).JSON(domain.ErrorResponse{ return c.Status(status).JSON(domain.ErrorResponse{
Message: "Failed to initiate subscription payment", Message: "Failed to initiate subscription payment",
Error: err.Error(), Error: err.Error(),