Fix review findings: expiry bug (all 8 files), idempotency keys, card_expiry/card_cvc removal, URL encoding, BuyerEmail logging, ValidateCardInfo, saved-card test, future work doc

Backend:
- Fix refund idempotency key: clock.Now() → deterministic (pr.ID + amount)
- Fix ValidateCardInfo: enforce mutual exclusivity, handle empty strings symmetrically
- Fix paymentFromSquare brand fallback (remove dead SourceType fallback)
- Fix URL encoding: PathEscape → QueryEscape for customer_id query param
- Fix BuyerEmail: log warning on DB error instead of silent discard
- Fix idempotency key in createCardOnFileHTTP: time.Now() → deterministic hex hash
- Add BuyerEmail to CreateTipPayment Square request
- Move realBaseURL from shared file to square_dev.go (only used in dev)
- Add TestTipPayment_WithSavedCard test (card_id path coverage)
- Fix AMEX brand in mock (AMEX → AMERICAN_EXPRESS, fix test)

Frontend:
- Fix off-by-month expiry bug in ALL 8 files using year-month arithmetic
  (parseExpiryParts returns 1-indexed, SvelteDate expects 0-indexed)
  Files: tip/+page, pay-tip/[id], UserBookingModal, UserPaymentModal,
  BookingFlow, account/+page (add card + buy gift card sections)
- Remove card_expiry/card_cvc from tip request bodies (backend has no fields)

Docs:
- Mark P9 (placeholder tokens) as completed, add P11 (Square Web Payments SDK)
- Mark T13 (rune arithmetic) as completed
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 4abcb324c9
commit 2459ddc919
13 changed files with 133 additions and 35 deletions
+7
View File
@@ -463,3 +463,10 @@ func isAllDigits(s string) bool {
}
return len(s) > 0
}
func realBaseURL(env string) string {
if env == "production" {
return squareProductionURL
}
return squareSandboxURL
}
+15 -11
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
@@ -339,8 +340,15 @@ func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
hc := newHTTPClient()
// Deterministic idempotency key derived from user + card (not time-based)
// so that retries with the same details don't create duplicate cards.
ikHash := fmt.Sprintf("%x", []byte(userID+"|"+cardToken))
if len(ikHash) > 64 {
ikHash = ikHash[:64]
}
body := sqCreateCardRequest{
IdempotencyKey: fmt.Sprintf("create-card-%d", time.Now().UnixNano()),
IdempotencyKey: fmt.Sprintf("create-card-%s", ikHash),
SourceID: cardToken,
Card: sqCardPayload{
CustomerID: userID,
@@ -358,7 +366,7 @@ func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardO
func getCardsOnFileHTTP(ctx context.Context, userID string) ([]CardOnFile, error) {
hc := newHTTPClient()
var resp sqListCardsResponse
if err := hc.doJSON(ctx, http.MethodGet, "/v2/cards?customer_id="+userID, nil, &resp); err != nil {
if err := hc.doJSON(ctx, http.MethodGet, "/v2/cards?customer_id="+url.QueryEscape(userID), nil, &resp); err != nil {
return nil, err
}
cards := make([]CardOnFile, 0, len(resp.Cards))
@@ -416,8 +424,11 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
r.ExpYear = cd.Card.ExpYear
}
}
if r.CardBrand == "" {
r.CardBrand = sq.SourceType
if r.CardBrand == "" && sq.CardDetails != nil && sq.CardDetails.Card.ID != "" {
r.CardBrand = sq.CardDetails.Card.CardBrand
}
if r.CardLast4 == "" && sq.CardDetails != nil && sq.CardDetails.Card.ID != "" {
r.CardLast4 = sq.CardDetails.Card.Last4
}
return r
}
@@ -479,10 +490,3 @@ func firstNonEmpty(vals ...string) string {
}
return ""
}
func realBaseURL(env string) string {
if env == "production" {
return squareProductionURL
}
return squareSandboxURL
}