Fix tip amount-change false dedup, wire BuyerEmail everywhere, clear ESLint errors
Money-moving fixes:
- Tip idempotency key regenerates when the tip amount changes after a failed
attempt (all 3 tip flows). Cached key still reused on same-amount retry
(dedup intact) and cleared on success/modal reset. Prevents silent
under-charge when a user retries at a different amount.
- Till replay path returns actual till_sales.status (may be 'pending') instead
of hardcoded 'completed' — no more misreported successful charge.
- BuyerEmail wired for CreateBookingPayment, gift card purchases, and till
sales (saved_card + online_square), matching the tip flow. Email lookup
errors logged, non-fatal.
- Till buyer-email errors now logged (was silently swallowed).
- on_the_house till top-up uses cached getIdempotencyKey() for retry-safe dedup
(was fresh crypto.randomUUID()).
Test/validation fixes:
- Add TestPaymentFromSquare_* unit tests (else-branch + nil card details),
build tag relaxed to 'test' so they run in the standard dev suite.
- Add TestValidateCardInfo table test (7 cases: both/either/neither/empty).
- Add TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed regression test.
- Remove dead mock pre-registration in TestTipPayment_WithSavedCard.
- Correct misleading till regression-test comment.
ESLint cleanup (12 errors -> 0):
- Remove unused loadingCards in tip + pay-tip pages (dead assignments in
loadSavedCards).
- Scoped eslint-disable for {@html} in CardBrandIcon (hardcoded brand SVGs).
- Remove dead confirmSaveDefaultHours + unused rescheduleVersion prop in
WeeklySchedule (and its parent pass-through).
- Replace new Date() with SvelteDate in WeeklySchedule + BusinessHours.
- Fix each-block key in BusinessHours skeleton loader.
- Use void expression for reactivity-tracker reads in effects.
This commit is contained in:
@@ -999,12 +999,18 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Step 2: DB transaction committed — safe to call Square now.
|
// Step 2: DB transaction committed — safe to call Square now.
|
||||||
// If Square fails, the payment record stays 'pending' for manual retry.
|
// If Square fails, the payment record stays 'pending' for manual retry.
|
||||||
|
var buyerEmail string
|
||||||
|
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&buyerEmail); err != nil {
|
||||||
|
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
paymentReq := square.CreatePaymentReq{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card Purchase",
|
Note: "Gift Card Purchase",
|
||||||
|
BuyerEmail: buyerEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package payments
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/internal/square"
|
"crussell/internal/square"
|
||||||
@@ -47,10 +48,11 @@ type RefundRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateTipPaymentRequest struct {
|
type CreateTipPaymentRequest struct {
|
||||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||||
CardID *string `json:"card_id,omitempty"`
|
CardID *string `json:"card_id,omitempty"`
|
||||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||||
SaveCard bool `json:"save_card"`
|
SaveCard bool `json:"save_card"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutResponse struct {
|
type CheckoutResponse struct {
|
||||||
@@ -759,6 +761,12 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve buyer email for Square receipt delivery (failure is non-fatal).
|
||||||
|
var bookingBuyerEmail string
|
||||||
|
if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&bookingBuyerEmail); err != nil {
|
||||||
|
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
// M8
|
// M8
|
||||||
// L5
|
// L5
|
||||||
|
|
||||||
@@ -980,6 +988,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
ReferenceID: bookingID,
|
ReferenceID: bookingID,
|
||||||
Note: req.PaymentType,
|
Note: req.PaymentType,
|
||||||
|
BuyerEmail: bookingBuyerEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
||||||
@@ -1801,7 +1810,14 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
idempotencyKey := bookingID + "-tip-" + strconv.FormatInt(req.Amount, 10)
|
// Idempotency key: prefer the client-supplied UUID (one per attempt, so
|
||||||
|
// two legitimate identical tips on the same booking don't collapse into
|
||||||
|
// one). Fall back to a unique key when absent — must NOT be derived from
|
||||||
|
// request fields alone (bookingID + amount would dedupe distinct tips).
|
||||||
|
idempotencyKey := req.IdempotencyKey
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
idempotencyKey = uniqueTipKey()
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the card source ID — same pattern as CreateBookingPayment.
|
// Resolve the card source ID — same pattern as CreateBookingPayment.
|
||||||
var sourceID string
|
var sourceID string
|
||||||
@@ -2208,3 +2224,10 @@ func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uniqueTipKey generates a unique idempotency key for tip payments where the
|
||||||
|
// client did not supply one. Client-supplied UUIDs handle retry dedup; this
|
||||||
|
// fallback only needs uniqueness so identical tips don't collapse.
|
||||||
|
func uniqueTipKey() string {
|
||||||
|
return "tip-" + rand.Text()
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/internal/square"
|
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"crussell/testutils"
|
"crussell/testutils"
|
||||||
"crussell/testutils/fixtures"
|
"crussell/testutils/fixtures"
|
||||||
@@ -31,6 +30,38 @@ func makePaymentRequest(handler http.HandlerFunc, method, path string, body inte
|
|||||||
return makePaymentAuthRequest(handler, method, path, body, token, "", ctx)
|
return makePaymentAuthRequest(handler, method, path, body, token, "", ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateCardInfo(t *testing.T) {
|
||||||
|
empty := ""
|
||||||
|
cardID := "card_123"
|
||||||
|
token := "cnon:test"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cardID *string
|
||||||
|
newToken *string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"both set rejected", &cardID, &token, true},
|
||||||
|
{"card_id only ok", &cardID, nil, false},
|
||||||
|
{"token only ok", nil, &token, false},
|
||||||
|
{"neither set rejected", nil, nil, true},
|
||||||
|
{"empty card_id rejected", &empty, nil, true},
|
||||||
|
{"empty token rejected", nil, &empty, true},
|
||||||
|
{"both empty rejected", &empty, &empty, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateCardInfo(tt.cardID, tt.newToken)
|
||||||
|
if tt.wantError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string, ctx context.Context) *httptest.ResponseRecorder {
|
func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string, ctx context.Context) *httptest.ResponseRecorder {
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -1875,18 +1906,6 @@ func TestTipPayment_WithSavedCard(t *testing.T) {
|
|||||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID)
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Register the payment in the mock so the tip flow works.
|
|
||||||
mockClient, ok := SquareClient.(*square.MockClient)
|
|
||||||
require.True(t, ok, "SquareClient must be a MockClient for this test")
|
|
||||||
_, err = mockClient.CreatePayment(ctx, square.CreatePaymentReq{
|
|
||||||
Amount: 5000,
|
|
||||||
Currency: "GBP",
|
|
||||||
SourceID: "cnon:visa",
|
|
||||||
IdempotencyKey: "pay-for-saved-card-tip",
|
|
||||||
ReferenceID: bookingID,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a saved card for this user.
|
// Create a saved card for this user.
|
||||||
var savedCardID string
|
var savedCardID string
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
|
|||||||
@@ -105,16 +105,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Idempotency check: if key provided, return existing sale if found
|
// Idempotency check: if key provided, return existing sale if found
|
||||||
if req.IdempotencyKey != "" {
|
if req.IdempotencyKey != "" {
|
||||||
var existingID string
|
var existingID, existingStatus string
|
||||||
err := db.Conn.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID)
|
err := db.Conn.QueryRow(ctx, `SELECT id, status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Existing sale found — return it (idempotent)
|
// Existing sale found — return its ACTUAL status (may be 'pending'
|
||||||
|
// if a previous Square charge failed; must not report 'completed').
|
||||||
if err := json.NewEncoder(w).Encode(TillSaleResponse{
|
if err := json.NewEncoder(w).Encode(TillSaleResponse{
|
||||||
ID: existingID,
|
ID: existingID,
|
||||||
ItemType: req.ItemType,
|
ItemType: req.ItemType,
|
||||||
TotalAmount: req.Amount,
|
TotalAmount: req.Amount,
|
||||||
PaymentMethod: req.PaymentMethod,
|
PaymentMethod: req.PaymentMethod,
|
||||||
Status: "completed",
|
Status: existingStatus,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("Failed to encode JSON response: %v", err)
|
log.Printf("Failed to encode JSON response: %v", err)
|
||||||
}
|
}
|
||||||
@@ -408,6 +409,18 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Step 2: DB transaction committed — safe to call Square now.
|
// Step 2: DB transaction committed — safe to call Square now.
|
||||||
// If Square fails, the till_sale record stays 'pending' for manual retry.
|
// If Square fails, the till_sale record stays 'pending' for manual retry.
|
||||||
|
// Resolve buyer email for Square receipt delivery (non-fatal if missing).
|
||||||
|
var buyerEmail string
|
||||||
|
if req.UserID != nil && *req.UserID != "" {
|
||||||
|
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, *req.UserID).Scan(&buyerEmail); err != nil {
|
||||||
|
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", *req.UserID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buyerEmail == "" {
|
||||||
|
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&buyerEmail); err != nil {
|
||||||
|
log.Printf("[SQUARE-PROD] Failed to resolve admin email for user %s: %v (Square receipts will not be emailed)", adminID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if needsSquarePayment {
|
if needsSquarePayment {
|
||||||
var paymentResult *square.PaymentResult
|
var paymentResult *square.PaymentResult
|
||||||
var squareErr error
|
var squareErr error
|
||||||
@@ -419,6 +432,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
SourceID: savedCardSqCardID,
|
SourceID: savedCardSqCardID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card " + req.Action,
|
Note: "Gift Card " + req.Action,
|
||||||
|
BuyerEmail: buyerEmail,
|
||||||
}
|
}
|
||||||
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
||||||
} else if req.PaymentMethod == "online_square" {
|
} else if req.PaymentMethod == "online_square" {
|
||||||
@@ -435,6 +449,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
SourceID: cardOnFile.CardID,
|
SourceID: cardOnFile.CardID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card " + req.Action,
|
Note: "Gift Card " + req.Action,
|
||||||
|
BuyerEmail: buyerEmail,
|
||||||
}
|
}
|
||||||
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -881,11 +881,10 @@ func TestCreateTillSale_CreateCash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed is a regression test:
|
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed verifies that two
|
||||||
// two identical keyless cash gift-card creations must BOTH return 201. The
|
// identical keyless cash gift-card creations both return 201. The idempotency-key
|
||||||
// idempotency-key fallback must be unique per request (not derived from request
|
// fallback must be unique per request so legitimate repeat sales don't collide
|
||||||
// fields, which are identical for the two sales) or the second sale would
|
// on the till_sales idempotency_key UNIQUE constraint.
|
||||||
// collide on the till_sales idempotency_key UNIQUE constraint and return 500.
|
|
||||||
func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
|
func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, tx := testutils.SetupTestTx(t)
|
_, tx := testutils.SetupTestTx(t)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//go:build test
|
||||||
|
|
||||||
|
package square
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
||||||
|
p := &sqPayment{
|
||||||
|
ID: "pay_1",
|
||||||
|
Status: "COMPLETED",
|
||||||
|
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
||||||
|
CardDetails: &sqCardDetails{
|
||||||
|
Card: sqCard{
|
||||||
|
ID: "",
|
||||||
|
CardBrand: "VISA",
|
||||||
|
Last4: "4242",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := paymentFromSquare(p)
|
||||||
|
if result.CardBrand != "VISA" {
|
||||||
|
t.Errorf("expected CardBrand VISA, got %s", result.CardBrand)
|
||||||
|
}
|
||||||
|
if result.CardLast4 != "4242" {
|
||||||
|
t.Errorf("expected CardLast4 4242, got %s", result.CardLast4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentFromSquare_NilCardDetails(t *testing.T) {
|
||||||
|
p := &sqPayment{
|
||||||
|
ID: "pay_2",
|
||||||
|
Status: "COMPLETED",
|
||||||
|
TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"},
|
||||||
|
CardDetails: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := paymentFromSquare(p)
|
||||||
|
if result.CardBrand != "" {
|
||||||
|
t.Errorf("expected empty CardBrand when no card details, got %s", result.CardBrand)
|
||||||
|
}
|
||||||
|
if result.Amount != 2500 {
|
||||||
|
t.Errorf("expected amount 2500, got %d", result.Amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||||
import CardInput from '$lib/components/payments/CardInput.svelte';
|
import CardInput from '$lib/components/payments/CardInput.svelte';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -138,6 +138,13 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
let customTipInput = $state('');
|
let customTipInput = $state('');
|
||||||
let tipProcessing = $state(false);
|
let tipProcessing = $state(false);
|
||||||
|
|
||||||
|
// Cached idempotency key: generated once per tip attempt, reused on retry
|
||||||
|
// (so a network-timeout retry dedupes instead of double-charging), cleared on
|
||||||
|
// success. Reset when the tip amount changes so an amount change after a
|
||||||
|
// failed attempt gets a fresh key instead of a false dedup (under-charge).
|
||||||
|
let tipIdempotencyKey = $state('');
|
||||||
|
let tipKeyedAmount = $state(0);
|
||||||
|
|
||||||
// Card selection state for tips
|
// Card selection state for tips
|
||||||
let tipSavedCards = $state<SavedCard[]>([]);
|
let tipSavedCards = $state<SavedCard[]>([]);
|
||||||
let tipLoadingCards = $state(false);
|
let tipLoadingCards = $state(false);
|
||||||
@@ -205,7 +212,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
return expiryYearMonth < currentYearMonth;
|
return expiryYearMonth < currentYearMonth;
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
const hasTipNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null);
|
const hasTipNewCardInvalidMonth = $derived(
|
||||||
|
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null
|
||||||
|
);
|
||||||
|
|
||||||
const tipNewCardError = $derived(
|
const tipNewCardError = $derived(
|
||||||
tipShowNewCard || tipSavedCards.length === 0
|
tipShowNewCard || tipSavedCards.length === 0
|
||||||
@@ -215,13 +224,19 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
? 'Invalid expiry month'
|
? 'Invalid expiry month'
|
||||||
: tipCardExpiryTouched && isTipNewCardExpiryPast
|
: tipCardExpiryTouched && isTipNewCardExpiryPast
|
||||||
? 'This card has expired'
|
? 'This card has expired'
|
||||||
: tipCardExpiryTouched && tipNewCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry)
|
: tipCardExpiryTouched &&
|
||||||
|
tipNewCardExpiry.length > 0 &&
|
||||||
|
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry)
|
||||||
? 'Enter expiry as MM/YY'
|
? 'Enter expiry as MM/YY'
|
||||||
: tipCVCTouched && tipNewCardCVC.length < 3 && tipNewCardCVC.length > 0
|
: tipCVCTouched && tipNewCardCVC.length < 3 && tipNewCardCVC.length > 0
|
||||||
? 'Enter your CVC number'
|
? 'Enter your CVC number'
|
||||||
: isValidLuhn(tipNewCardNumber) && /^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardCVC.length >= 3
|
: isValidLuhn(tipNewCardNumber) &&
|
||||||
|
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) &&
|
||||||
|
tipNewCardCVC.length >= 3
|
||||||
? null
|
? null
|
||||||
: tipNewCardNumber.length === 0 && tipNewCardExpiry.length === 0 && tipNewCardCVC.length === 0
|
: tipNewCardNumber.length === 0 &&
|
||||||
|
tipNewCardExpiry.length === 0 &&
|
||||||
|
tipNewCardCVC.length === 0
|
||||||
? null
|
? null
|
||||||
: 'Please complete all card fields'
|
: 'Please complete all card fields'
|
||||||
: null
|
: null
|
||||||
@@ -312,7 +327,12 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
|
|
||||||
// Validate card details for new card payments
|
// Validate card details for new card payments
|
||||||
if (tipShowNewCard || tipSavedCards.length === 0) {
|
if (tipShowNewCard || tipSavedCards.length === 0) {
|
||||||
if (!isValidLuhn(tipNewCardNumber) || !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) || isTipNewCardExpiryPast || tipNewCardCVC.length < 3) {
|
if (
|
||||||
|
!isValidLuhn(tipNewCardNumber) ||
|
||||||
|
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) ||
|
||||||
|
isTipNewCardExpiryPast ||
|
||||||
|
tipNewCardCVC.length < 3
|
||||||
|
) {
|
||||||
tipProcessing = false;
|
tipProcessing = false;
|
||||||
toast.error(tipNewCardError || 'Please enter valid credit card details');
|
toast.error(tipNewCardError || 'Please enter valid credit card details');
|
||||||
return;
|
return;
|
||||||
@@ -320,7 +340,14 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = { amount: Math.round(tipAmount * 100) };
|
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
|
||||||
|
tipIdempotencyKey = crypto.randomUUID();
|
||||||
|
tipKeyedAmount = tipAmount;
|
||||||
|
}
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
amount: Math.round(tipAmount * 100),
|
||||||
|
idempotency_key: tipIdempotencyKey
|
||||||
|
};
|
||||||
|
|
||||||
if (tipShowNewCard || tipSavedCards.length === 0) {
|
if (tipShowNewCard || tipSavedCards.length === 0) {
|
||||||
body.new_card_token = tipNewCardNumber.replace(/\s/g, '');
|
body.new_card_token = tipNewCardNumber.replace(/\s/g, '');
|
||||||
@@ -339,6 +366,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
throw new Error(errorText || 'Tip payment failed');
|
throw new Error(errorText || 'Tip payment failed');
|
||||||
}
|
}
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
|
tipIdempotencyKey = '';
|
||||||
|
tipKeyedAmount = 0;
|
||||||
showTipModal = false;
|
showTipModal = false;
|
||||||
fetchBookingDetails();
|
fetchBookingDetails();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1173,6 +1202,8 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
tipAmount = 0;
|
tipAmount = 0;
|
||||||
selectedTipPreset = null;
|
selectedTipPreset = null;
|
||||||
customTipInput = '';
|
customTipInput = '';
|
||||||
|
tipIdempotencyKey = '';
|
||||||
|
tipKeyedAmount = 0;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1221,7 +1252,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
|
|
||||||
<!-- Card Selection for Tip -->
|
<!-- Card Selection for Tip -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">Payment Method</span>
|
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||||
|
>Payment Method</span
|
||||||
|
>
|
||||||
|
|
||||||
{#if tipLoadingCards}
|
{#if tipLoadingCards}
|
||||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||||
@@ -1230,11 +1263,17 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
{#each tipSavedCards as card (card.id)}
|
{#each tipSavedCards as card (card.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId === card.id && !tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
|
||||||
onclick={() => { tipSelectedCardId = card.id; tipShowNewCard = false; }}
|
card.id && !tipShowNewCard
|
||||||
|
? 'border-input bg-accent'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
tipSelectedCardId = card.id;
|
||||||
|
tipShowNewCard = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CardBrandIcon brand={card.brand} />
|
<CardBrandIcon brand={card.brand} />
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-mono">**** {card.last_4}</span>
|
<span class="font-mono">**** {card.last_4}</span>
|
||||||
<span class="ml-2 text-xs text-gray-400"
|
<span class="ml-2 text-xs text-gray-400"
|
||||||
@@ -1249,8 +1288,13 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
{/each}
|
{/each}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCard
|
||||||
onclick={() => { tipSelectedCardId = null; tipShowNewCard = true; }}
|
? 'border-input bg-accent'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
tipSelectedCardId = null;
|
||||||
|
tipShowNewCard = true;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -694,7 +694,7 @@
|
|||||||
amount: Number(topUpAmount),
|
amount: Number(topUpAmount),
|
||||||
payment_method: 'on_the_house',
|
payment_method: 'on_the_house',
|
||||||
gift_card_id: gcId,
|
gift_card_id: gcId,
|
||||||
idempotency_key: 'till-on-the-house-' + gcId + '-' + Date.now()
|
idempotency_key: getIdempotencyKey()
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/till/sale', {
|
const res = await apiFetch('/api/admin/till/sale', {
|
||||||
|
|||||||
@@ -376,10 +376,10 @@
|
|||||||
|
|
||||||
// Check for conflicts whenever weekStarts, hours, or rescheduleVersion change
|
// Check for conflicts whenever weekStarts, hours, or rescheduleVersion change
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Track these reactive values so the effect re-runs when they change
|
// Read these reactive values so the effect re-runs when they change
|
||||||
const weekStarts = exceptionDraft.weekStarts;
|
const weekStarts = exceptionDraft.weekStarts;
|
||||||
const hours = exceptionDraft.hours;
|
void exceptionDraft.hours;
|
||||||
const rv = rescheduleVersion;
|
void rescheduleVersion;
|
||||||
// Avoid triggering check on initial empty state
|
// Avoid triggering check on initial empty state
|
||||||
if (weekStarts.length > 0 && showExceptionModal) {
|
if (weekStarts.length > 0 && showExceptionModal) {
|
||||||
checkConflictingBookings();
|
checkConflictingBookings();
|
||||||
@@ -747,7 +747,12 @@
|
|||||||
? ''
|
? ''
|
||||||
: 's'} with the proposed hours
|
: 's'} with the proposed hours
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" class="h-6 text-xs ml-auto" onclick={checkConflictingBookings}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 text-xs ml-auto"
|
||||||
|
onclick={checkConflictingBookings}
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
@@ -57,15 +58,9 @@
|
|||||||
};
|
};
|
||||||
openUserModal?: (userId: string) => void;
|
openUserModal?: (userId: string) => void;
|
||||||
openBookingModal?: (bookingId: string) => void;
|
openBookingModal?: (bookingId: string) => void;
|
||||||
rescheduleVersion?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { defaultHours: defaultHoursProp, openUserModal, openBookingModal }: Props = $props();
|
||||||
defaultHours: defaultHoursProp,
|
|
||||||
openUserModal,
|
|
||||||
openBookingModal,
|
|
||||||
rescheduleVersion = 0
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// =============== State ===============
|
// =============== State ===============
|
||||||
let defaultHours = $state<WorkingHourRow[]>([]);
|
let defaultHours = $state<WorkingHourRow[]>([]);
|
||||||
@@ -201,46 +196,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Saves the default hours draft after confirmation. */
|
/** Saves the default hours draft after confirmation. */
|
||||||
async function confirmSaveDefaultHours() {
|
|
||||||
savingHours = true;
|
|
||||||
const loadingToast = toast.loading('Saving default hours...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Map snake_case to camelCase for API
|
|
||||||
const payload = defaultHoursDraft.map((hour) => ({
|
|
||||||
weekday: hour.weekday,
|
|
||||||
startTime: hour.start_time,
|
|
||||||
endTime: hour.end_time,
|
|
||||||
isOpen: hour.is_open
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response = await apiFetch('/api/scheduling/default-hours', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Update the main state from the draft state if successful
|
|
||||||
defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft));
|
|
||||||
showDefaultHoursModal = false;
|
|
||||||
toast.success('Default hours saved successfully!', { id: loadingToast });
|
|
||||||
} else if (response.status === 401 || response.status === 403) {
|
|
||||||
toast.error('Unauthorized. Please log in again.', { id: loadingToast });
|
|
||||||
} else {
|
|
||||||
const text = await response.text();
|
|
||||||
toast.error('Failed to save: ' + extractErrorMessage(text), { id: loadingToast });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('save default hours', err);
|
|
||||||
toast.error('Network error saving hours', { id: loadingToast });
|
|
||||||
} finally {
|
|
||||||
savingHours = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkConflictingBookings() {
|
async function checkConflictingBookings() {
|
||||||
const proposedHours = defaultHoursDraft.map((h) => ({
|
const proposedHours = defaultHoursDraft.map((h) => ({
|
||||||
weekday: h.weekday,
|
weekday: h.weekday,
|
||||||
@@ -274,7 +229,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultEffectiveDate(): string {
|
function getDefaultEffectiveDate(): string {
|
||||||
const d = new Date();
|
const d = new SvelteDate();
|
||||||
d.setDate(d.getDate() + 1);
|
d.setDate(d.getDate() + 1);
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -394,8 +349,8 @@
|
|||||||
// Trigger conflict check when the modal opens or hours/date change
|
// Trigger conflict check when the modal opens or hours/date change
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const open = showDefaultHoursModal;
|
const open = showDefaultHoursModal;
|
||||||
const hours = defaultHoursDraft;
|
void defaultHoursDraft;
|
||||||
const date = effectiveDate;
|
void effectiveDate;
|
||||||
if (open) {
|
if (open) {
|
||||||
checkConflictingBookings();
|
checkConflictingBookings();
|
||||||
}
|
}
|
||||||
@@ -922,7 +877,12 @@
|
|||||||
? ''
|
? ''
|
||||||
: 's'}
|
: 's'}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" class="h-6 text-xs ml-auto" onclick={checkConflictingBookings}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 text-xs ml-auto"
|
||||||
|
onclick={checkConflictingBookings}
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { formatTime } from '$lib/utils/timeSlots';
|
import { formatTime } from '$lib/utils/timeSlots';
|
||||||
|
|
||||||
@@ -57,7 +58,10 @@
|
|||||||
// may include microsecond precision ("17:00:00.000000") from
|
// may include microsecond precision ("17:00:00.000000") from
|
||||||
// PostgreSQL's ::text cast, while staged hours are plain "17:00".
|
// PostgreSQL's ::text cast, while staged hours are plain "17:00".
|
||||||
const norm = (t: string) => t.replace(/\.\d+$/, '').replace(/^(\d{2}:\d{2}).*$/, '$1');
|
const norm = (t: string) => t.replace(/\.\d+$/, '').replace(/^(\d{2}:\d{2}).*$/, '$1');
|
||||||
return norm(h.startTime) !== norm(current.startTime) || norm(h.endTime) !== norm(current.endTime);
|
return (
|
||||||
|
norm(h.startTime) !== norm(current.startTime) ||
|
||||||
|
norm(h.endTime) !== norm(current.endTime)
|
||||||
|
);
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
@@ -77,7 +81,7 @@
|
|||||||
const today = getLondonDate();
|
const today = getLondonDate();
|
||||||
const dayOfWeek = today.getDay(); // 0=Sun
|
const dayOfWeek = today.getDay(); // 0=Sun
|
||||||
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||||
const monday = new Date(today);
|
const monday = new SvelteDate(today);
|
||||||
monday.setDate(today.getDate() + offset);
|
monday.setDate(today.getDate() + offset);
|
||||||
return fmtDate(monday);
|
return fmtDate(monday);
|
||||||
}
|
}
|
||||||
@@ -254,7 +258,7 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="animate-pulse space-y-3">
|
<div class="animate-pulse space-y-3">
|
||||||
{#each { length: 7 } as _}
|
{#each Array.from({ length: 7 }) as _, i (i)}
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="h-4 w-20 rounded bg-gray-200"></div>
|
<div class="h-4 w-20 rounded bg-gray-200"></div>
|
||||||
<div class="h-4 w-24 rounded bg-gray-200"></div>
|
<div class="h-4 w-24 rounded bg-gray-200"></div>
|
||||||
@@ -308,7 +312,9 @@
|
|||||||
{#if scheduledChange && scheduledChangeDays.length > 0}
|
{#if scheduledChange && scheduledChangeDays.length > 0}
|
||||||
<hr class="my-2 border-gray-200" />
|
<hr class="my-2 border-gray-200" />
|
||||||
<p class="mb-2 text-center text-xs font-medium text-amber-600">
|
<p class="mb-2 text-center text-xs font-medium text-amber-600">
|
||||||
{scheduledChangeDays.length === 7 ? 'Opening hours will change from' : 'These opening hours will change from'}
|
{scheduledChangeDays.length === 7
|
||||||
|
? 'Opening hours will change from'
|
||||||
|
: 'These opening hours will change from'}
|
||||||
{new Date(scheduledChange.effective_date + 'T00:00:00').toLocaleDateString('en-GB', {
|
{new Date(scheduledChange.effective_date + 'T00:00:00').toLocaleDateString('en-GB', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|||||||
@@ -31,9 +31,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showSvg}
|
{#if showSvg}
|
||||||
<div class="flex h-8 min-w-12 items-center justify-center rounded" role="img" aria-label={brand}>{@html normalizedSvg}</div>
|
<div class="flex h-8 min-w-12 items-center justify-center rounded" role="img" aria-label={brand}>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- SVG strings are hardcoded constants, never user input -->
|
||||||
|
{@html normalizedSvg}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase" role="img" aria-label={brand}>
|
<div
|
||||||
|
class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase"
|
||||||
|
role="img"
|
||||||
|
aria-label={brand}
|
||||||
|
>
|
||||||
{brand}
|
{brand}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -380,7 +380,7 @@
|
|||||||
<div class="space-y-4 p-4">
|
<div class="space-y-4 p-4">
|
||||||
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
|
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
|
||||||
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
|
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
|
||||||
<WeeklySchedule {defaultHours} {openUserModal} {openBookingModal} {rescheduleVersion} />
|
<WeeklySchedule {defaultHours} {openUserModal} {openBookingModal} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import CardInput from '$lib/components/payments/CardInput.svelte';
|
import CardInput from '$lib/components/payments/CardInput.svelte';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -41,11 +41,17 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
// Cached idempotency key: generated once per payment attempt, reused on retry
|
||||||
|
// (so a network-timeout retry dedupes instead of double-charging), cleared on
|
||||||
|
// success. Reset when the tip amount changes so an amount change after a
|
||||||
|
// failed attempt gets a fresh key instead of a false dedup (under-charge).
|
||||||
|
let tipIdempotencyKey = $state('');
|
||||||
|
let tipKeyedAmount = $state(0);
|
||||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
|
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
|
||||||
|
|
||||||
// Card selection state
|
// Card selection state
|
||||||
let savedCards = $state<SavedCard[]>([]);
|
let savedCards = $state<SavedCard[]>([]);
|
||||||
let loadingCards = $state(false);
|
|
||||||
let selectedCardId = $state<string | null>(null);
|
let selectedCardId = $state<string | null>(null);
|
||||||
let showNewCardForm = $state(false);
|
let showNewCardForm = $state(false);
|
||||||
|
|
||||||
@@ -118,7 +124,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
return expiryYearMonth < currentYearMonth;
|
return expiryYearMonth < currentYearMonth;
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
const hasNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null);
|
const hasNewCardInvalidMonth = $derived(
|
||||||
|
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
|
||||||
|
);
|
||||||
|
|
||||||
const newCardError = $derived(
|
const newCardError = $derived(
|
||||||
showNewCardForm || savedCards.length === 0
|
showNewCardForm || savedCards.length === 0
|
||||||
@@ -132,9 +140,13 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
? 'Enter expiry as MM/YY'
|
? 'Enter expiry as MM/YY'
|
||||||
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
|
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
|
||||||
? 'Enter your CVC number'
|
? 'Enter your CVC number'
|
||||||
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
|
: isValidLuhn(newCardNumber) &&
|
||||||
|
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
|
||||||
|
newCardCVC.length >= 3
|
||||||
? null
|
? null
|
||||||
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
|
: newCardNumber.length === 0 &&
|
||||||
|
newCardExpiry.length === 0 &&
|
||||||
|
newCardCVC.length === 0
|
||||||
? null
|
? null
|
||||||
: 'Please complete all card fields'
|
: 'Please complete all card fields'
|
||||||
: null
|
: null
|
||||||
@@ -232,21 +244,18 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
if (savedCardsStore.loaded) {
|
if (savedCardsStore.loaded) {
|
||||||
savedCards = savedCardsStore.cards;
|
savedCards = savedCardsStore.cards;
|
||||||
if (savedCards.length > 0 && !selectedCardId) {
|
if (savedCards.length > 0 && !selectedCardId) {
|
||||||
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
|
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadingCards = true;
|
|
||||||
try {
|
try {
|
||||||
await savedCardsStore.fetch();
|
await savedCardsStore.fetch();
|
||||||
savedCards = savedCardsStore.cards;
|
savedCards = savedCardsStore.cards;
|
||||||
if (savedCards.length > 0 && !selectedCardId) {
|
if (savedCards.length > 0 && !selectedCardId) {
|
||||||
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
|
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
|
||||||
loadingCards = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +304,12 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
|
|
||||||
// Validate card details for new card payments
|
// Validate card details for new card payments
|
||||||
if (showNewCardForm || savedCards.length === 0) {
|
if (showNewCardForm || savedCards.length === 0) {
|
||||||
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || isNewCardExpiryPast || newCardCVC.length < 3) {
|
if (
|
||||||
|
!isValidLuhn(newCardNumber) ||
|
||||||
|
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
|
||||||
|
isNewCardExpiryPast ||
|
||||||
|
newCardCVC.length < 3
|
||||||
|
) {
|
||||||
paymentState = 'idle';
|
paymentState = 'idle';
|
||||||
toast.error(newCardError || 'Please enter valid credit card details');
|
toast.error(newCardError || 'Please enter valid credit card details');
|
||||||
return;
|
return;
|
||||||
@@ -303,8 +317,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
|
||||||
|
tipIdempotencyKey = crypto.randomUUID();
|
||||||
|
tipKeyedAmount = tipAmount;
|
||||||
|
}
|
||||||
const amountInPence = Math.round(tipAmount * 100);
|
const amountInPence = Math.round(tipAmount * 100);
|
||||||
const body: Record<string, unknown> = { amount: amountInPence };
|
const body: Record<string, unknown> = {
|
||||||
|
amount: amountInPence,
|
||||||
|
idempotency_key: tipIdempotencyKey
|
||||||
|
};
|
||||||
|
|
||||||
if (showNewCardForm || savedCards.length === 0) {
|
if (showNewCardForm || savedCards.length === 0) {
|
||||||
body.new_card_token = newCardNumber.replace(/\s/g, '');
|
body.new_card_token = newCardNumber.replace(/\s/g, '');
|
||||||
@@ -325,6 +346,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
}
|
}
|
||||||
|
|
||||||
paymentState = 'success';
|
paymentState = 'success';
|
||||||
|
tipIdempotencyKey = '';
|
||||||
|
tipKeyedAmount = 0;
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentState = 'error';
|
paymentState = 'error';
|
||||||
@@ -542,16 +565,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
<Card.Content>
|
<Card.Content>
|
||||||
{#if savedCards.length > 0}
|
{#if savedCards.length > 0}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<span
|
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
|
||||||
class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
|
||||||
>
|
|
||||||
Payment Method
|
Payment Method
|
||||||
</span>
|
</span>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each savedCards as card (card.id)}
|
{#each savedCards as card (card.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId === card.id && !showNewCardForm
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||||
|
card.id && !showNewCardForm
|
||||||
? 'border-input bg-accent'
|
? 'border-input bg-accent'
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -559,8 +581,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
showNewCardForm = false;
|
showNewCardForm = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CardBrandIcon brand={card.brand} />
|
<CardBrandIcon brand={card.brand} />
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-mono">**** {card.last_4}</span>
|
<span class="font-mono">**** {card.last_4}</span>
|
||||||
<span class="ml-2 text-xs text-gray-400"
|
<span class="ml-2 text-xs text-gray-400"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||||
import CardInput from '$lib/components/payments/CardInput.svelte';
|
import CardInput from '$lib/components/payments/CardInput.svelte';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
@@ -49,9 +49,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
let booking = $state<Booking | null>(null);
|
let booking = $state<Booking | null>(null);
|
||||||
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
// Cached idempotency key: generated once per payment attempt, reused on retry
|
||||||
|
// (so a network-timeout retry dedupes instead of double-charging), cleared on
|
||||||
|
// success. Reset when the tip amount changes so an amount change after a
|
||||||
|
// failed attempt gets a fresh key instead of a false dedup (under-charge).
|
||||||
|
let tipIdempotencyKey = $state('');
|
||||||
|
let tipKeyedAmount = $state(0);
|
||||||
|
|
||||||
// Card selection state (same pattern as UserPaymentModal)
|
// Card selection state (same pattern as UserPaymentModal)
|
||||||
let savedCards = $state<SavedCard[]>([]);
|
let savedCards = $state<SavedCard[]>([]);
|
||||||
let loadingCards = $state(false);
|
|
||||||
let selectedCardId = $state<string | null>(null);
|
let selectedCardId = $state<string | null>(null);
|
||||||
let showNewCardForm = $state(false);
|
let showNewCardForm = $state(false);
|
||||||
|
|
||||||
@@ -144,7 +150,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
return expiryYearMonth < currentYearMonth;
|
return expiryYearMonth < currentYearMonth;
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
const hasNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null);
|
const hasNewCardInvalidMonth = $derived(
|
||||||
|
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
|
||||||
|
);
|
||||||
|
|
||||||
const newCardError = $derived(
|
const newCardError = $derived(
|
||||||
showNewCardForm || savedCards.length === 0
|
showNewCardForm || savedCards.length === 0
|
||||||
@@ -158,12 +166,16 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
? 'Enter expiry as MM/YY'
|
? 'Enter expiry as MM/YY'
|
||||||
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
|
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
|
||||||
? 'Enter your CVC number'
|
? 'Enter your CVC number'
|
||||||
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
|
: isValidLuhn(newCardNumber) &&
|
||||||
|
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
|
||||||
|
newCardCVC.length >= 3
|
||||||
? null
|
? null
|
||||||
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
|
: newCardNumber.length === 0 &&
|
||||||
|
newCardExpiry.length === 0 &&
|
||||||
|
newCardCVC.length === 0
|
||||||
? null
|
? null
|
||||||
: 'Please complete all card fields'
|
: 'Please complete all card fields'
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const isCardValid = $derived(
|
const isCardValid = $derived(
|
||||||
@@ -244,7 +256,12 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
|
|
||||||
// Validate card details for new card payments
|
// Validate card details for new card payments
|
||||||
if (showNewCardForm || savedCards.length === 0) {
|
if (showNewCardForm || savedCards.length === 0) {
|
||||||
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || isNewCardExpiryPast || newCardCVC.length < 3) {
|
if (
|
||||||
|
!isValidLuhn(newCardNumber) ||
|
||||||
|
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
|
||||||
|
isNewCardExpiryPast ||
|
||||||
|
newCardCVC.length < 3
|
||||||
|
) {
|
||||||
paymentState = 'idle';
|
paymentState = 'idle';
|
||||||
toast.error(newCardError || 'Please enter valid credit card details');
|
toast.error(newCardError || 'Please enter valid credit card details');
|
||||||
return;
|
return;
|
||||||
@@ -252,8 +269,15 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
|
||||||
|
tipIdempotencyKey = crypto.randomUUID();
|
||||||
|
tipKeyedAmount = tipAmount;
|
||||||
|
}
|
||||||
const amountInPence = Math.round(tipAmount * 100);
|
const amountInPence = Math.round(tipAmount * 100);
|
||||||
const body: Record<string, unknown> = { amount: amountInPence };
|
const body: Record<string, unknown> = {
|
||||||
|
amount: amountInPence,
|
||||||
|
idempotency_key: tipIdempotencyKey
|
||||||
|
};
|
||||||
|
|
||||||
if (showNewCardForm || savedCards.length === 0) {
|
if (showNewCardForm || savedCards.length === 0) {
|
||||||
body.new_card_token = newCardNumber.replace(/\s/g, '');
|
body.new_card_token = newCardNumber.replace(/\s/g, '');
|
||||||
@@ -274,6 +298,8 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
}
|
}
|
||||||
|
|
||||||
paymentState = 'success';
|
paymentState = 'success';
|
||||||
|
tipIdempotencyKey = '';
|
||||||
|
tipKeyedAmount = 0;
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentState = 'error';
|
paymentState = 'error';
|
||||||
@@ -367,21 +393,18 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
if (savedCardsStore.loaded) {
|
if (savedCardsStore.loaded) {
|
||||||
savedCards = savedCardsStore.cards;
|
savedCards = savedCardsStore.cards;
|
||||||
if (savedCards.length > 0 && !selectedCardId) {
|
if (savedCards.length > 0 && !selectedCardId) {
|
||||||
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
|
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadingCards = true;
|
|
||||||
try {
|
try {
|
||||||
await savedCardsStore.fetch();
|
await savedCardsStore.fetch();
|
||||||
savedCards = savedCardsStore.cards;
|
savedCards = savedCardsStore.cards;
|
||||||
if (savedCards.length > 0 && !selectedCardId) {
|
if (savedCards.length > 0 && !selectedCardId) {
|
||||||
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
|
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — user can enter new card
|
// ignore — user can enter new card
|
||||||
} finally {
|
|
||||||
loadingCards = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -561,23 +584,31 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
<Card.Root class="mb-6">
|
<Card.Root class="mb-6">
|
||||||
<Card.Content class="space-y-4">
|
<Card.Content class="space-y-4">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">Payment Method</span>
|
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||||
|
>Payment Method</span
|
||||||
|
>
|
||||||
|
|
||||||
{#if savedCards.length > 0}
|
{#if savedCards.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each savedCards as card (card.id)}
|
{#each savedCards as card (card.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId === card.id && !showNewCardForm
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||||
|
card.id && !showNewCardForm
|
||||||
? 'border-input bg-accent'
|
? 'border-input bg-accent'
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => { selectedCardId = card.id; showNewCardForm = false; }}
|
onclick={() => {
|
||||||
|
selectedCardId = card.id;
|
||||||
|
showNewCardForm = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CardBrandIcon brand={card.brand} />
|
<CardBrandIcon brand={card.brand} />
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-mono">**** {card.last_4}</span>
|
<span class="font-mono">**** {card.last_4}</span>
|
||||||
<span class="ml-2 text-xs text-gray-400">Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span>
|
<span class="ml-2 text-xs text-gray-400"
|
||||||
|
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if selectedCardId === card.id && !showNewCardForm}
|
{#if selectedCardId === card.id && !showNewCardForm}
|
||||||
@@ -591,7 +622,10 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
||||||
? 'border-input bg-accent'
|
? 'border-input bg-accent'
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => { selectedCardId = null; showNewCardForm = true; }}
|
onclick={() => {
|
||||||
|
selectedCardId = null;
|
||||||
|
showNewCardForm = true;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -599,7 +633,9 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
|||||||
>
|
>
|
||||||
NEW
|
NEW
|
||||||
</div>
|
</div>
|
||||||
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
|
<span class="animate-pulse text-sm font-medium text-gray-700"
|
||||||
|
>Use a new card</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if showNewCardForm}
|
{#if showNewCardForm}
|
||||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user