Square payment integration: real HTTP client, tip flow rewrite, card UI/validation overhaul
Backend: - Create square_http_client.go: real Square REST API client (Payments, Terminal Checkouts, Refunds, Cards, Locations) with proper JSON types, auth, error handling - Update ProdClient in square.go to delegate to shared HTTP functions - Wire devProdClient in square_dev.go to also make real HTTP calls for sandbox/prod env - Rewrite CreateTipPayment handler: accept card_id OR new_card_token (+save_card), advisory lock, idempotency check, max amount validation - Add ValidateCardInfo, bump ValidateAmount max to £10,000 - Fix mock CreateCardOnFile to detect brand/last4 from raw card numbers - Fix mock RefundPayment to index by SquarePayID and accept unknown payment IDs - Remove dead types (ProcessingFee, sqAddress), add Deadline parity - Fix AMEX brand inconsistency (AMEX -> AMERICAN_EXPRESS) - Pre-existing fix: remove unused context import in giftcards.go Frontend: - CardInput.svelte: add onfieldblur/onfieldinput callbacks for blur-based validation - CardBrandIcon.svelte: brand SVGs for VISA, MC, AMEX, Discover, Diners, JCB, Square Gift Card, UnionPay, Interac, EFTPOS - tip/+page, pay-tip/[id], UserBookingModal tip: saved card list + CardInput + Luhn/expiry/CVC validation + blur-based errors + no-saved-cards edge case - UserPaymentModal, BookingFlow: card validation parity (blur-based, all-valid check) - account page: replace text brand badges with CardBrandIcon - Fix handleCustomTip bug (state mutations outside if block) - Remove dead pageState variable - Add tip modal scroll (max-h-[90vh] overflow-y-auto) - Submit button disabled on !isCardValid Tests: - 30 square package tests (+new: CreateCardOnFile raw number path, detectCardInfo variants) - 5 tip handler tests (HappyPath, NoPriorPayment, WrongOwner, MultipleTips, TxFailure) - All +-race clean, refund tests fixed
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@@ -47,8 +47,10 @@ type RefundRequest struct {
|
||||
}
|
||||
|
||||
type CreateTipPaymentRequest struct {
|
||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||
CardToken string `json:"card_token" validate:"required"`
|
||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||
CardID *string `json:"card_id,omitempty"`
|
||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||
SaveCard bool `json:"save_card"`
|
||||
}
|
||||
|
||||
type CheckoutResponse struct {
|
||||
@@ -1757,17 +1759,15 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// M8
|
||||
// L5
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.CardToken == "" {
|
||||
http.Error(w, "Card token is required", http.StatusBadRequest)
|
||||
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1803,21 +1803,75 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
idempotencyKey := bookingID + "-tip-" + strconv.FormatInt(req.Amount, 10)
|
||||
|
||||
// Step 1: Insert payment record in 'pending' state inside a DB transaction.
|
||||
// Square is NOT called yet — if the tx fails, no harm done.
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "pending",
|
||||
Amount: float64(req.Amount) / 100.0,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: 0,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &userID,
|
||||
// Resolve the card source ID — same pattern as CreateBookingPayment.
|
||||
var sourceID string
|
||||
var savedCardID *string
|
||||
|
||||
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
||||
cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), userID, *req.NewCardToken)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create card on file: %v", err)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sourceID = cardOnFile.CardID
|
||||
|
||||
if req.SaveCard {
|
||||
cardID, err := service.SaveCardForUser(r.Context(), userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
|
||||
if err != nil {
|
||||
log.Printf("Failed to save card: %v", err)
|
||||
} else {
|
||||
savedCardID = &cardID
|
||||
}
|
||||
}
|
||||
if savedCardID == nil && req.SaveCard {
|
||||
log.Printf("Card was not saved despite save_card=true for user %s", userID)
|
||||
}
|
||||
} else if req.CardID != nil {
|
||||
card, err := service.GetCardByID(r.Context(), *req.CardID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sourceID = card.SquareCardID
|
||||
savedCardID = req.CardID
|
||||
}
|
||||
|
||||
// Serialize tip attempts for this booking to prevent concurrent duplicate
|
||||
// tip payments across browser tabs or retries. Uses a PostgreSQL session-level
|
||||
// advisory lock scoped to the booking ID.
|
||||
// See CreateBookingPayment lines 815-846 for the same pattern.
|
||||
pinConn, err := db.Conn.Acquire(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for tip lock: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer pinConn.Release()
|
||||
|
||||
if _, err := pinConn.Exec(r.Context(), `
|
||||
SELECT pg_advisory_lock(hashtext('crussell:tip:' || $1))
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to acquire tip serialization lock for %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if _, err := pinConn.Exec(context.Background(), `
|
||||
SELECT pg_advisory_unlock(hashtext('crussell:tip:' || $1))
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to release tip serialization lock for %s: %v", bookingID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Step 1: Insert payment record in 'pending' state inside a DB transaction.
|
||||
// Square is NOT called yet — if the tx fails, no harm done.
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
@@ -1830,6 +1884,47 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Check idempotency inside the transaction — same pattern as CreateBookingPayment.
|
||||
var existingID sql.NullString
|
||||
var existingBookingID sql.NullString
|
||||
var existingPaymentType sql.NullString
|
||||
var existingStatus sql.NullString
|
||||
var existingAmount sql.NullFloat64
|
||||
var existingCreatedAt sql.NullTime
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT id, booking_id, payment_type, status, amount, created_at
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND idempotency_key = $2
|
||||
`, bookingID, idempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil {
|
||||
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: existingID.String,
|
||||
BookingID: existingBookingID.String,
|
||||
PaymentType: existingPaymentType.String,
|
||||
Status: existingStatus.String,
|
||||
Amount: int64(existingAmount.Float64 * 100),
|
||||
CreatedAt: existingCreatedAt.Time.Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to check tip idempotency: %v", err)
|
||||
}
|
||||
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "pending",
|
||||
Amount: float64(req.Amount) / 100.0,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: 0,
|
||||
UserSavedCardID: savedCardID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
|
||||
paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment record: %v", err)
|
||||
@@ -1849,7 +1944,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
SourceID: req.CardToken,
|
||||
SourceID: sourceID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
ReferenceID: bookingID,
|
||||
Note: "tip",
|
||||
|
||||
@@ -699,8 +699,8 @@ func TestTipPayment_HappyPath(t *testing.T) {
|
||||
|
||||
cardToken := "cnon:tip-card"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardToken: cardToken,
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
@@ -734,8 +734,8 @@ func TestTipPayment_NoPriorPayment(t *testing.T) {
|
||||
|
||||
cardToken := "cnon:tip-card"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardToken: cardToken,
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
@@ -1806,9 +1806,10 @@ func TestTipPayment_WrongOwnerRejected(t *testing.T) {
|
||||
|
||||
otherToken := jwt.GenerateUserToken(otherUserID)
|
||||
|
||||
cardToken := "cnon:wrong-owner-tip"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardToken: "cnon:wrong-owner-tip",
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
@@ -1833,9 +1834,10 @@ func TestTipPayment_MultipleTipsAllowed(t *testing.T) {
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
cardToken := fmt.Sprintf("cnon:multi-tip-%d", i)
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: int64(200 + i*100),
|
||||
CardToken: fmt.Sprintf("cnon:multi-tip-%d", i),
|
||||
Amount: int64(200 + i*100),
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
@@ -1872,9 +1874,10 @@ func TestTipPayment_TransactionFailure_SkipsSquare(t *testing.T) {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
cardToken := "cnon:tip-card"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardToken: "cnon:tip-card",
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
|
||||
@@ -19,8 +19,9 @@ func ValidateAmount(amount int64) error {
|
||||
if amount <= 0 {
|
||||
return errors.New("amount must be greater than 0")
|
||||
}
|
||||
// Amount is in pence (integer), so no precision issues possible at this level
|
||||
// The frontend must ensure the input has max 2 decimal places before converting to pence
|
||||
if amount > 1_000_000 { // £10,000 in pence
|
||||
return errors.New("amount exceeds maximum (£10,000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user