feat: Square payment integration, booking flow redesign, and timezone/weekday fixes
- Add Square payment integration (mock + handlers + UI): terminal/online payments, refunds, tips, saved cards, webhooks. Build-tagged dev/prod clients. - Redesign booking flow: Step 4 conditional (deposit only), Step 5 confirmation screen with booking ID, auto-submit on transition. - Redesign schedule modal: 2x3 button grid with Pay Deposit/Pay Early logic. - Add deposit warning banner at Step 1 for users with outstanding deposits. - Fix weekday conversion bug: Go 0=Sunday vs DB 0=Monday mismatch in 6 locations. - Fix timezone bug: UTC vs London time in closing hours validation. - Fix frontend error parsing: plain text backend errors now displayed correctly. - Fix crypto.randomUUID fallback for environments without Web Crypto. - Add 7 new regression tests: closing hours, advance check, active booking limit, weekday conversion, UTC/London, deposit snapshot, exceptional hours. - Fix 3 flaky tests: dynamic dates instead of fixed, no-show timing.
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type SavedCard struct {
|
||||
ID string `json:"id"`
|
||||
SquareCardID string `json:"square_card_id"`
|
||||
Brand string `json:"brand"`
|
||||
Last4 string `json:"last_4"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type PaymentService struct{}
|
||||
|
||||
func NewPaymentService() *PaymentService {
|
||||
return &PaymentService{}
|
||||
}
|
||||
|
||||
type PaymentRecord struct {
|
||||
ID string
|
||||
BookingID string
|
||||
PaymentType string
|
||||
PaymentMethod string
|
||||
VendorCode *string
|
||||
InvoiceNumber *int
|
||||
Status string
|
||||
Amount int64
|
||||
IsVATApplicable bool
|
||||
VATRate *float64
|
||||
VATAmount *int64
|
||||
NetAmount *int64
|
||||
UserSavedCardID *string
|
||||
SquarePaymentID *string
|
||||
IdempotencyKey *string
|
||||
Fees int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
}
|
||||
|
||||
type RefundRecord struct {
|
||||
ID string
|
||||
PaymentID string
|
||||
BookingID string
|
||||
Amount int64
|
||||
SquareRefundID *string
|
||||
Status string
|
||||
Reason string
|
||||
CreatedBy *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PaymentSummary struct {
|
||||
TotalAmount int64
|
||||
PaidAmount int64
|
||||
RefundedAmount int64
|
||||
RemainingAmount int64
|
||||
Payments []PaymentRecord
|
||||
Refunds []RefundRecord
|
||||
}
|
||||
|
||||
func (s *PaymentService) CalculateFees(amount int64, method string) int64 {
|
||||
if method == "online" {
|
||||
return (amount * 14 / 1000) + 25
|
||||
}
|
||||
return (amount * 175 / 10000)
|
||||
}
|
||||
|
||||
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) {
|
||||
var id string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id
|
||||
`,
|
||||
record.BookingID,
|
||||
record.PaymentType,
|
||||
record.PaymentMethod,
|
||||
record.VendorCode,
|
||||
record.InvoiceNumber,
|
||||
record.Status,
|
||||
record.Amount,
|
||||
record.IsVATApplicable,
|
||||
record.VATRate,
|
||||
record.VATAmount,
|
||||
record.NetAmount,
|
||||
record.UserSavedCardID,
|
||||
record.SquarePaymentID,
|
||||
record.IdempotencyKey,
|
||||
record.Fees,
|
||||
record.CreatedAt,
|
||||
record.UpdatedAt,
|
||||
record.CreatedBy,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRecord) (string, error) {
|
||||
var id string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO refunds (
|
||||
payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
record.PaymentID,
|
||||
record.BookingID,
|
||||
record.Amount,
|
||||
record.SquareRefundID,
|
||||
record.Status,
|
||||
record.Reason,
|
||||
record.CreatedBy,
|
||||
record.CreatedAt,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID string) (*PaymentSummary, error) {
|
||||
summary := &PaymentSummary{
|
||||
Payments: []PaymentRecord{},
|
||||
Refunds: []RefundRecord{},
|
||||
}
|
||||
|
||||
var totalAmount int64
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(
|
||||
COALESCE(bs.override_price, s.price)
|
||||
), 0)
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
`, bookingID).Scan(&totalAmount)
|
||||
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
summary.TotalAmount = totalAmount
|
||||
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
FROM payments
|
||||
WHERE booking_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, bookingID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var paidAmount int64
|
||||
for rows.Next() {
|
||||
var p PaymentRecord
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.Payments = append(summary.Payments, p)
|
||||
if p.Status == "completed" {
|
||||
paidAmount += p.Amount
|
||||
}
|
||||
}
|
||||
summary.PaidAmount = paidAmount
|
||||
|
||||
refundRows, err := db.DB.Query(ctx, `
|
||||
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||
FROM refunds
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
ORDER BY created_at ASC
|
||||
`, bookingID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer refundRows.Close()
|
||||
|
||||
var refundedAmount int64
|
||||
for refundRows.Next() {
|
||||
var r RefundRecord
|
||||
err := refundRows.Scan(
|
||||
&r.ID, &r.PaymentID, &r.BookingID, &r.Amount, &r.SquareRefundID,
|
||||
&r.Status, &r.Reason, &r.CreatedBy, &r.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.Refunds = append(summary.Refunds, r)
|
||||
refundedAmount += r.Amount
|
||||
}
|
||||
summary.RefundedAmount = refundedAmount
|
||||
summary.RemainingAmount = totalAmount - paidAmount + refundedAmount
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempotencyKey string) (*PaymentRecord, error) {
|
||||
var p PaymentRecord
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND idempotency_key = $2
|
||||
`, bookingID, idempotencyKey).Scan(
|
||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (*PaymentRecord, error) {
|
||||
var p PaymentRecord
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by
|
||||
FROM payments
|
||||
WHERE id = $1
|
||||
`, paymentID).Scan(
|
||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
|
||||
var amount int64
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0) FROM refunds
|
||||
WHERE payment_id = $1 AND status = 'completed'
|
||||
`, paymentID).Scan(&amount)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) UpdateBookingDepositPaid(ctx context.Context, bookingID string, depositPaid bool) error {
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
UPDATE bookings SET deposit_paid = $1 WHERE id = $2
|
||||
`, depositPaid, bookingID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) {
|
||||
var count int
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed' AND payment_type IN ('full', 'deposit', 'balance', 'partial')
|
||||
`, bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetBookingStatus(ctx context.Context, bookingID string) (string, error) {
|
||||
var status string
|
||||
err := db.DB.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) (string, error) {
|
||||
var userID string
|
||||
err := db.DB.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
|
||||
FROM user_saved_cards
|
||||
WHERE user_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY is_default DESC, created_at DESC
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cards []SavedCard
|
||||
for rows.Next() {
|
||||
var c SavedCard
|
||||
err := rows.Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards = append(cards, c)
|
||||
}
|
||||
|
||||
if cards == nil {
|
||||
cards = []SavedCard{}
|
||||
}
|
||||
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID string) error {
|
||||
retainedUntil := time.Now().Add(7 * 365 * 24 * time.Hour)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
UPDATE user_saved_cards
|
||||
SET deleted_at = NOW(), deleted_by = $1, retained_until = $2
|
||||
WHERE id = $3 AND user_id = $1
|
||||
`, userID, retainedUntil, cardID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
|
||||
var id string
|
||||
err := db.DB.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, $5, $6, $7, false, NOW())
|
||||
RETURNING id
|
||||
`, userID, squareCardID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) (*SavedCard, error) {
|
||||
var c SavedCard
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
|
||||
FROM user_saved_cards
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||
`, cardID, userID).Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
var SquareClient square.SquareClient
|
||||
Reference in New Issue
Block a user