S SmartDocs
系列: Go go 132 行 · 更新于 2026-04-18

payment.go

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

// Package validation implements the Checkout.com assessment validation rules
// for inbound payment requests. Any failure produces a human-readable reason
// suitable for a "Rejected" response without contacting the acquiring bank.
package validation

import (
	"fmt"
	"regexp"
	"strings"
	"time"
)

// ProcessPaymentRequest is the public JSON contract for POST /api/payments.
// `example` tags are consumed by Swag when generating OpenAPI / Swagger UI.
type ProcessPaymentRequest struct {
	CardNumber  string `json:"card_number" example:"2222405343248877"`
	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"`
	CVV         string `json:"cvv" example:"123"`
}

var digitsOnly = regexp.MustCompile(`^[0-9]+$`)

// ValidateProcessRequest applies every rule from the recruitment README table.
// allowedCurrencies must already be normalised uppercase (max 3 codes).
func ValidateProcessRequest(r ProcessPaymentRequest, allowedCurrencies []string, now time.Time) []string {
	var errs []string
	add := func(msg string) { errs = append(errs, msg) }

	pan := strings.TrimSpace(r.CardNumber)
	if pan == "" {
		add("card_number is required")
	} else {
		if len(pan) < 14 || len(pan) > 19 {
			add("card_number must be between 14 and 19 characters long")
		}
		if !digitsOnly.MatchString(pan) {
			add("card_number must only contain numeric characters")
		}
	}

	if r.ExpiryMonth < 1 || r.ExpiryMonth > 12 {
		add("expiry_month must be between 1 and 12")
	}

	if r.ExpiryYear < 2000 || r.ExpiryYear > 2100 {
		// Not explicitly in spec — guards obviously bogus years before date logic.
		add("expiry_year is out of supported range")
	} else if r.ExpiryMonth >= 1 && r.ExpiryMonth <= 12 {
		// Cards remain valid through the end of the printed expiry month.
		// (Industry-standard interpretation; avoids rejecting mid-month edge cases.)
		expEnd := time.Date(r.ExpiryYear, time.Month(r.ExpiryMonth+1), 0, 23, 59, 59, 999999999, time.UTC)
		if expEnd.Before(now.UTC()) {
			add("expiry month and year combination must still be valid (card appears expired)")
		}
	}

	cur := strings.TrimSpace(strings.ToUpper(r.Currency))
	if cur == "" {
		add("currency is required")
	} else if len(cur) != 3 {
		add("currency must be exactly 3 characters (ISO 4217)")
	} else {
		ok := false
		for _, c := range allowedCurrencies {
			if c == cur {
				ok = true
				break
			}
		}
		if !ok {
			add(fmt.Sprintf("currency must be one of: %s", strings.Join(allowedCurrencies, ", ")))
		}
	}

	if r.Amount <= 0 {
		add("amount must be a positive integer (minor currency units)")
	}

	cvv := strings.TrimSpace(r.CVV)
	if cvv == "" {
		add("cvv is required")
	} else {
		if len(cvv) < 3 || len(cvv) > 4 {
			add("cvv must be 3-4 characters long")
		}
		if !digitsOnly.MatchString(cvv) {
			add("cvv must only contain numeric characters")
		}
	}

	return errs
}

// BankSimulatorBody is the JSON contract expected by the Mountebank simulator.
type BankSimulatorBody struct {
	CardNumber  string `json:"card_number"`
	ExpiryDate  string `json:"expiry_date"` // MM/YYYY
	Currency    string `json:"currency"`
	Amount      int64  `json:"amount"`
	CVV         string `json:"cvv"`
}

// ToBankRequest maps a validated merchant request to the simulator shape.
func ToBankRequest(r ProcessPaymentRequest) BankSimulatorBody {
	return BankSimulatorBody{
		CardNumber: strings.TrimSpace(r.CardNumber),
		ExpiryDate: fmt.Sprintf("%02d/%04d", r.ExpiryMonth, r.ExpiryYear),
		Currency:   strings.TrimSpace(strings.ToUpper(r.Currency)),
		Amount:     r.Amount,
		CVV:        strings.TrimSpace(r.CVV),
	}
}

// LastFourDigits returns the final four digits of the PAN for safe storage/display.
func LastFourDigits(pan string) string {
	pan = strings.TrimSpace(pan)
	if len(pan) < 4 {
		return ""
	}
	return pan[len(pan)-4:]
}

// MaskPAN renders a PCI-friendly mask: (len-4) asterisks + last four digits.
func MaskPAN(lastFour string, panLength int) string {
	if panLength < 4 || len(lastFour) != 4 {
		return "****"
	}
	return strings.Repeat("*", panLength-4) + lastFour
}

相关文章