Serie: Go
go
224 righe
· Aggiornato 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,
})
}
Articoli correlati
Go
go
Aggiornato 2026-04-18
main.go
main.go — go source code from the Go learning materials (Go/Baasid/cmd/hashpw/main.go).
Leggi l'articolo →
Go
go
Aggiornato 2026-04-18
main.go
main.go — go source code from the Go learning materials (Go/Baasid/cmd/server/main.go).
Leggi l'articolo →
Go
go
Aggiornato 2026-04-18
docs.go
docs.go — go source code from the Go learning materials (Go/Baasid/docs/docs.go).
Leggi l'articolo →
Go
go
Aggiornato 2026-04-18
jwt.go
jwt.go — go source code from the Go learning materials (Go/Baasid/internal/auth/jwt.go).
Leggi l'articolo →
Go
go
Aggiornato 2026-04-18
password.go
password.go — go source code from the Go learning materials (Go/Baasid/internal/auth/password.go).
Leggi l'articolo →
Go
go
Aggiornato 2026-04-18
password_test.go
password_test.go — go source code from the Go learning materials (Go/Baasid/internal/auth/password_test.go).
Leggi l'articolo →