Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
357 lines
11 KiB
Go
357 lines
11 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package fixtures
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Global counter for unique emails in tests
|
|
var testEmailCounter atomic.Int64
|
|
|
|
func CreateTestAdminUser(q db.Querier) (string, error) {
|
|
return createTestUser(q, "Admin", "User", "", "admin")
|
|
}
|
|
|
|
func CreateTestUser(q db.Querier) (string, error) {
|
|
return createTestUser(q, "Test", "User", "", "verified_email")
|
|
}
|
|
|
|
func CreateTestUserWithEmail(q db.Querier, email, role string) (string, error) {
|
|
return createTestUser(q, "Test", "User", email, role)
|
|
}
|
|
|
|
func createTestUser(q db.Querier, firstName, lastName, email, role string) (string, error) {
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Generate unique email if not provided
|
|
if email == "" {
|
|
n := testEmailCounter.Add(1)
|
|
email = fmt.Sprintf("%s.%s.%d@test.com", firstName, lastName, n)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var userID string
|
|
err = q.QueryRow(ctx, `
|
|
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'email')
|
|
RETURNING id
|
|
`, firstName, lastName, email, "+447123456789", "1990-01-01", string(passwordHash), role).Scan(&userID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
return userID, nil
|
|
}
|
|
|
|
func CreateTestService(q db.Querier) (string, error) {
|
|
ctx := context.Background()
|
|
var serviceID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
`, "Test Service", "A test service for unit tests", 50.00, 60, true, 16).Scan(&serviceID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create service: %w", err)
|
|
}
|
|
|
|
return serviceID, nil
|
|
}
|
|
|
|
func CreateTestServiceWithDuration(q db.Querier, durationMinutes int) (string, error) {
|
|
ctx := context.Background()
|
|
var serviceID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
`, fmt.Sprintf("Test Service %dmin", durationMinutes), "A test service for unit tests", 50.00, durationMinutes, true, 16).Scan(&serviceID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create service: %w", err)
|
|
}
|
|
return serviceID, nil
|
|
}
|
|
|
|
// CreateTestServiceWithPatchTest creates a service and a patch test that links to it
|
|
// Returns serviceID, patchTestID
|
|
func CreateTestServiceWithPatchTest(q db.Querier) (string, string, error) {
|
|
ctx := context.Background()
|
|
|
|
// First create the service
|
|
var serviceID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
`, "Test Patch Test Service", "A test service requiring patch test", 75.00, 90, true, 18).Scan(&serviceID)
|
|
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to create service: %w", err)
|
|
}
|
|
|
|
// Now create a patch test that links to this service
|
|
var patchTestID string
|
|
err = q.QueryRow(ctx, `
|
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, "Test Patch Test", "A patch test for testing", 24, 6, []string{serviceID}).Scan(&patchTestID)
|
|
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to create patch test: %w", err)
|
|
}
|
|
|
|
return serviceID, patchTestID, nil
|
|
}
|
|
|
|
// CreateTestPatchTest creates a patch test definition
|
|
func CreateTestPatchTest(q db.Querier, serviceIDs []string) (string, error) {
|
|
ctx := context.Background()
|
|
var patchTestID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, "Test Patch Test", "A patch test for testing", 24, 6, serviceIDs).Scan(&patchTestID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create patch test: %w", err)
|
|
}
|
|
|
|
return patchTestID, nil
|
|
}
|
|
|
|
// CreateUserPatchTest creates a user patch test record
|
|
func CreateUserPatchTest(q db.Querier, userID, patchTestID string, testedAt string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, `
|
|
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
|
VALUES ($1, $2, $3)
|
|
`, userID, patchTestID, testedAt)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create user patch test: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func CreateTestBooking(q db.Querier, userID, serviceID string) (string, error) {
|
|
return CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
}
|
|
|
|
func CreateTestBookingAtTime(q db.Querier, userID, serviceID string, startTime time.Time) (string, error) {
|
|
ctx := context.Background()
|
|
var bookingID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, userID, startTime, "pending", "Test booking").Scan(&bookingID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create booking: %w", err)
|
|
}
|
|
|
|
_, err = q.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ($1, $2)
|
|
`, bookingID, serviceID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to link service to booking: %w", err)
|
|
}
|
|
|
|
return bookingID, nil
|
|
}
|
|
|
|
// NextWorkingDayAt returns the time at `hour` UTC on a day `daysAhead` days from
|
|
// now. Hours in [8, 18] are guaranteed inside the fixture's Mon-Sun 08:00-20:00
|
|
// London working hours at any wall-clock time (London is at most UTC+1), unlike
|
|
// clock.Now().Add(N * time.Hour), which can land after closing and flake tests.
|
|
func NextWorkingDayAt(daysAhead, hour int) time.Time {
|
|
day := clock.Now().AddDate(0, 0, daysAhead)
|
|
return time.Date(day.Year(), day.Month(), day.Day(), hour, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func CreateTestVerifiedUser(q db.Querier) (string, error) {
|
|
return createTestUser(q, "Verified", "User", "verified@test.com", "verified_email")
|
|
}
|
|
|
|
func CreateTestUnverifiedUser(q db.Querier) (string, error) {
|
|
return createTestUser(q, "Unverified", "User", "unverified@test.com", "unverified_email")
|
|
}
|
|
|
|
func CreateTestGuestUser(q db.Querier) (string, error) {
|
|
return createTestUser(q, "Guest", "User", "guest@test.com", "guest")
|
|
}
|
|
|
|
func DeleteUser(q db.Querier, userID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
|
|
return err
|
|
}
|
|
|
|
func DeleteService(q db.Querier, serviceID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM services WHERE id = $1", serviceID)
|
|
return err
|
|
}
|
|
|
|
func DeleteBooking(q db.Querier, bookingID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM bookings WHERE id = $1", bookingID)
|
|
return err
|
|
}
|
|
|
|
func CreateTestCustomService(q db.Querier) (string, error) {
|
|
ctx := context.Background()
|
|
var serviceID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO custom_services (name, description, price, duration_minutes, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, "Test Custom Service", "A test custom service", 75.00, 45, 16).Scan(&serviceID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create custom service: %w", err)
|
|
}
|
|
|
|
return serviceID, nil
|
|
}
|
|
|
|
func DeleteCustomService(q db.Querier, serviceID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM custom_services WHERE id = $1", serviceID)
|
|
return err
|
|
}
|
|
|
|
// SafeDeleteUser wraps DeleteUser and returns error (for tests that care about cleanup failure)
|
|
func SafeDeleteUser(q db.Querier, userID string) error {
|
|
return DeleteUser(q, userID)
|
|
}
|
|
|
|
// SafeDeleteService wraps DeleteService and returns error (for tests that care about cleanup failure)
|
|
func SafeDeleteService(q db.Querier, serviceID string) error {
|
|
return DeleteService(q, serviceID)
|
|
}
|
|
|
|
// SafeDeleteBooking wraps DeleteBooking and returns error (for tests that care about cleanup failure)
|
|
func SafeDeleteBooking(q db.Querier, bookingID string) error {
|
|
return DeleteBooking(q, bookingID)
|
|
}
|
|
|
|
// CreateTestTimeBlocker creates a time blocker for testing
|
|
// Returns the blocker ID
|
|
func CreateTestTimeBlocker(q db.Querier, startTime time.Time, durationMinutes int, description string) (string, error) {
|
|
ctx := context.Background()
|
|
var blockerID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, startTime, durationMinutes, description).Scan(&blockerID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create time blocker: %w", err)
|
|
}
|
|
|
|
return blockerID, nil
|
|
}
|
|
|
|
// DeleteTimeBlocker removes a time blocker from the database
|
|
func DeleteTimeBlocker(q db.Querier, blockerID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM time_blockers WHERE id = $1", blockerID)
|
|
return err
|
|
}
|
|
|
|
// CreateTestPayment creates a payment record for testing
|
|
// Returns payment ID
|
|
func CreateTestPayment(q db.Querier, bookingID string, amount float64, method string, ptype string, status string) (string, error) {
|
|
ctx := context.Background()
|
|
var paymentID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
|
RETURNING id
|
|
`, bookingID, ptype, method, status, amount).Scan(&paymentID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create payment: %w", err)
|
|
}
|
|
|
|
return paymentID, nil
|
|
}
|
|
|
|
// CreateTestRefund creates a refund record for testing
|
|
// Returns refund ID
|
|
func CreateTestRefund(q db.Querier, paymentID string, bookingID string, amount float64) (string, error) {
|
|
ctx := context.Background()
|
|
var refundID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
|
VALUES ($1, $2, $3, 'completed', 'test refund', NOW())
|
|
RETURNING id
|
|
`, paymentID, bookingID, amount).Scan(&refundID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create refund: %w", err)
|
|
}
|
|
|
|
return refundID, nil
|
|
}
|
|
|
|
// CreateTestPaymentMethod creates a saved card for a user
|
|
// Returns card ID
|
|
func CreateTestPaymentMethod(q db.Querier, userID string, squareCardID string, brand string, last4 string) (string, error) {
|
|
ctx := context.Background()
|
|
var cardID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at)
|
|
VALUES ($1, $2, $3, $4, 12, 2030, 'test_fp', false, NOW())
|
|
RETURNING id
|
|
`, userID, squareCardID, brand, last4).Scan(&cardID)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create payment method: %w", err)
|
|
}
|
|
|
|
return cardID, nil
|
|
}
|
|
|
|
// DeletePayment deletes a payment from the database
|
|
func DeletePayment(q db.Querier, paymentID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID)
|
|
return err
|
|
}
|
|
|
|
// DeleteRefund deletes a refund from the database
|
|
func DeleteRefund(q db.Querier, refundID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM refunds WHERE id = $1", refundID)
|
|
return err
|
|
}
|
|
|
|
// DeletePaymentMethod deletes a saved card from the database
|
|
func DeletePaymentMethod(q db.Querier, cardID string) error {
|
|
ctx := context.Background()
|
|
_, err := q.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", cardID)
|
|
return err
|
|
}
|