S SmartDocs
Chuỗi bài: Go go 224 dòng · Cập nhật 2026-04-18

payment.go

Go/payment-gateway-challenge/internal/handlers/payment.go

// Package handlers contains Echo HTTP handlers for the payment gateway API.
package handlers

import (
	"context"
	"errors"
	"net/http"
	"strings"
	"time"

	"payment-gateway-challenge/internal/bank"
	"payment-gateway-challenge/internal/config"
	"payment-gateway-challenge/internal/domain"
	"payment-gateway-challenge/internal/models"
	"payment-gateway-challenge/internal/repository"
	"payment-gateway-challenge/internal/validation"

	"github.com/google/uuid"
	"github.com/labstack/echo/v4"
	"gorm.io/gorm"
)

// PaymentAPI wires HTTP to validation, bank simulator, and persistence.
type PaymentAPI struct {
	cfg     *config.Config
	repo    repository.PaymentWriterReader
	bank    *bank.Client
	clockFn func() time.Time // injectable for tests; nil => time.Now
}

// NewPaymentAPI constructs the handler bundle.
func NewPaymentAPI(cfg *config.Config, repo repository.PaymentWriterReader, bankClient *bank.Client) *PaymentAPI {
	return &PaymentAPI{cfg: cfg, repo: repo, bank: bankClient}
}

func (a *PaymentAPI) now() time.Time {
	if a.clockFn != nil {
		return a.clockFn()
	}
	return time.Now()
}

// ProcessPaymentResponse is returned for Authorized / Declined (HTTP 201).
type ProcessPaymentResponse struct {
	ID                 string `json:"id" example:"550e8400-e29b-41d4-a716-446655440000"`
	Status             string `json:"status" example:"Authorized"`
	LastFourCardDigits string `json:"last_four_card_digits" example:"8877"`
	ExpiryMonth        int    `json:"expiry_month" example:"4"`
	ExpiryYear         int    `json:"expiry_year" example:"2028"`
	Currency           string `json:"currency" example:"GBP"`
	Amount             int64  `json:"amount" example:"100"`
}

// PaymentRejectedResponse is returned with HTTP 400 when validation fails before bank call.
type PaymentRejectedResponse struct {
	Status  string   `json:"status" example:"Rejected"`
	Message string   `json:"message" example:"invalid payment information"`
	Errors  []string `json:"errors"`
}

// PaymentDetailResponse is returned for GET /api/payments/:id (spec + masked PAN).
type PaymentDetailResponse struct {
	ID                 string `json:"id"`
	Status             string `json:"status" example:"Authorized"`
	MaskedCardNumber   string `json:"masked_card_number" example:"************8877"`
	LastFourCardDigits string `json:"last_four_card_digits" example:"8877"`
	ExpiryMonth        int    `json:"expiry_month" example:"4"`
	ExpiryYear         int    `json:"expiry_year" example:"2028"`
	Currency           string `json:"currency" example:"GBP"`
	Amount             int64  `json:"amount" example:"100"`
}

// ProcessPayment godoc
// @Summary      Process a card payment
// @Description  Validates the request, calls the acquiring-bank simulator, persists **Authorized** or **Declined** outcomes, and returns a payment id. **Rejected** (validation) returns 400 without calling the bank. PAN ending in `0` yields bank 503 → gateway 503 (no row stored).
// @Tags         payments
// @Accept       json
// @Produce      json
// @Param        body  body      validation.ProcessPaymentRequest  true  "Card and amount payload"
// @Success      201   {object}  ProcessPaymentResponse     "Payment created (Authorized or Declined)"
// @Failure      400   {object}  PaymentRejectedResponse    "Rejected — invalid input"
// @Failure      502   {object}  ErrorJSON                  "Bad gateway — bank unreachable or unexpected response"
// @Failure      503   {object}  ErrorJSON                  "Bank unavailable (e.g. test PAN ending in 0)"
// @Failure      500   {object}  ErrorJSON                  "Persistence failure"
// @Router       /api/payments [post]
func (a *PaymentAPI) ProcessPayment(c echo.Context) error {
	var req validation.ProcessPaymentRequest
	if err := c.Bind(&req); err != nil {
		return c.JSON(http.StatusBadRequest, PaymentRejectedResponse{
			Status:  domain.StatusRejected,
			Message: "invalid JSON body",
			Errors:  []string{err.Error()},
		})
	}

	errs := validation.ValidateProcessRequest(req, a.cfg.AllowedCurrencies, a.now())
	if len(errs) > 0 {
		// Assessment: Rejected — gateway must not call the bank.
		return c.JSON(http.StatusBadRequest, PaymentRejectedResponse{
			Status:  domain.StatusRejected,
			Message: "invalid payment information",
			Errors:  errs,
		})
	}

	bankBody := validation.ToBankRequest(req)
	ctx, cancel := context.WithTimeout(c.Request().Context(), a.cfg.HTTPClientTimeout)
	defer cancel()

	status, br, raw, err := a.bank.SubmitPayment(ctx, bankBody)
	if err != nil {
		c.Logger().Errorf("bank call failed: %v", err)
		return c.JSON(http.StatusBadGateway, ErrorJSON{Message: "failed to reach acquiring bank"})
	}

	switch status {
	case http.StatusServiceUnavailable:
		// Simulator rule: PAN ending in 0 → 503 — propagate as service unavailable.
		return c.JSON(http.StatusServiceUnavailable, ErrorJSON{
			Message: "acquiring bank temporarily unavailable",
		})
	case http.StatusBadRequest:
		return c.JSON(http.StatusBadGateway, ErrorJSON{
			Message: "bank rejected request shape",
			Detail:  string(raw),
		})
	case http.StatusOK:
		// continue
	default:
		return c.JSON(http.StatusBadGateway, ErrorJSON{
			Message: "unexpected bank HTTP status",
			Status:  status,
		})
	}

	if br == nil {
		return c.JSON(http.StatusBadGateway, ErrorJSON{Message: "empty bank response"})
	}

	paymentID := uuid.New()
	pan := strings.TrimSpace(req.CardNumber)
	last4 := validation.LastFourDigits(pan)
	panLen := len(pan) // ASCII digits only after validation

	var statusStr string
	if br.Authorized {
		statusStr = domain.StatusAuthorized
	} else {
		statusStr = domain.StatusDeclined
	}

	var authPtr *string
	if br.AuthorizationCode != "" {
		ac := br.AuthorizationCode
		authPtr = &ac
	}

	row := &models.Payment{
		ID:                  paymentID,
		Status:              statusStr,
		CardLast4:           last4,
		CardNumberLength:    panLen,
		ExpiryMonth:         req.ExpiryMonth,
		ExpiryYear:          req.ExpiryYear,
		Currency:            strings.ToUpper(strings.TrimSpace(req.Currency)),
		Amount:              req.Amount,
		AuthorizationCode:   authPtr,
	}

	if err := a.repo.Create(c.Request().Context(), row); err != nil {
		c.Logger().Errorf("persist payment: %v", err)
		return c.JSON(http.StatusInternalServerError, ErrorJSON{Message: "failed to persist payment"})
	}

	return c.JSON(http.StatusCreated, ProcessPaymentResponse{
		ID:                 paymentID.String(),
		Status:             statusStr,
		LastFourCardDigits: last4,
		ExpiryMonth:        req.ExpiryMonth,
		ExpiryYear:         req.ExpiryYear,
		Currency:           row.Currency,
		Amount:             req.Amount,
	})
}

// GetPayment godoc
// @Summary      Get payment by id
// @Description  Returns masked PAN, last four digits, expiry, currency, amount, and status (**Authorized** or **Declined**).
// @Tags         payments
// @Produce      json
// @Param        id   path      string  true  "Payment UUID"
// @Success      200  {object}  PaymentDetailResponse
// @Failure      400  {object}  ErrorJSON
// @Failure      404  {object}  ErrorJSON
// @Failure      500  {object}  ErrorJSON
// @Router       /api/payments/{id} [get]
func (a *PaymentAPI) GetPayment(c echo.Context) error {
	id, err := uuid.Parse(c.Param("id"))
	if err != nil {
		return c.JSON(http.StatusBadRequest, ErrorJSON{Message: "id must be a UUID"})
	}

	p, err := a.repo.GetByID(c.Request().Context(), id)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return c.JSON(http.StatusNotFound, ErrorJSON{Message: "payment not found"})
		}
		c.Logger().Errorf("load payment: %v", err)
		return c.JSON(http.StatusInternalServerError, ErrorJSON{Message: "failed to load payment"})
	}

	mask := validation.MaskPAN(p.CardLast4, p.CardNumberLength)

	return c.JSON(http.StatusOK, PaymentDetailResponse{
		ID:                 p.ID.String(),
		Status:             p.Status,
		MaskedCardNumber:   mask,
		LastFourCardDigits: p.CardLast4,
		ExpiryMonth:        p.ExpiryMonth,
		ExpiryYear:         p.ExpiryYear,
		Currency:           p.Currency,
		Amount:             p.Amount,
	})
}

Bài viết liên quan