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:
2026-08-22 00:34:49 +01:00
parent f6caaab8a3
commit 4abcb324c9
17 changed files with 2171 additions and 358 deletions
-1
View File
@@ -1,7 +1,6 @@
package payments
import (
"context"
"database/sql"
"encoding/json"
"errors"
+116 -21
View File
@@ -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",
+13 -10
View File
@@ -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
+3 -2
View File
@@ -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
}