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:
parent
82de00b1e7
commit
7a4253edf4
12
docs/docs.go
12
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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
36
internal/domain/payment_test.go
Normal file
36
internal/domain/payment_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user