package payments import ( "context" "crussell/clock" "crussell/db" "crussell/internal/square" "errors" "fmt" "log" "log/slog" "strconv" "strings" "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 float64 CardLast4 string IsVATApplicable bool VATRate *float64 VATAmount *float64 NetAmount *float64 UserSavedCardID *string SquarePaymentID *string IdempotencyKey *string Fees float64 CreatedAt time.Time UpdatedAt time.Time CreatedBy *string GiftCardID *string } type RefundRecord struct { ID string PaymentID string BookingID string Amount float64 SquareRefundID *string Status string Reason string CreatedBy *string CreatedAt time.Time } type PaymentSummary struct { TotalAmount float64 PaidAmount float64 RefundedAmount float64 RemainingAmount float64 TotalVATAmount float64 TotalNetAmount float64 Payments []PaymentRecord Refunds []RefundRecord } func (s *PaymentService) CalculateFees(amount int64, method string) float64 { if method == "online" { return float64((amount*14/1000)+25) / 100.0 } return float64(amount*175/10000) / 100.0 } func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) { return s.insertPaymentRecord(ctx, record, giftCardID, db.Conn) } // CreatePaymentRecordTx is identical to CreatePaymentRecord but accepts a // pgx.Tx so the insert is part of an existing database transaction. This // is used by CreateBookingPayment when inserting multiple split records // from a single Square charge — wrapping both inserts in a transaction // ensures atomicity (both succeed or both roll back). func (s *PaymentService) CreatePaymentRecordTx(ctx context.Context, tx pgx.Tx, record PaymentRecord, giftCardID *string) (string, error) { return s.insertPaymentRecord(ctx, record, giftCardID, tx) } // insertPaymentRecord holds the common INSERT logic. The querier parameter // accepts either *pgxpool.Pool or pgx.Tx so callers can choose transactional // or non-transactional insertion. type querier interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } func (s *PaymentService) insertPaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string, q querier) (string, error) { var bookingID *string if record.BookingID != "" { bookingID = &record.BookingID } var id string err := q.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, gift_card_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id `, 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, giftCardID, ).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.Conn.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 float64 err := db.Conn.QueryRow(ctx, ` SELECT total_amount FROM bookings WHERE id = $1 `, bookingID).Scan(&totalAmount) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } summary.TotalAmount = totalAmount rows, err := db.Conn.Query(ctx, ` SELECT p.id, p.booking_id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number, p.status, p.amount, COALESCE(usc.last_4, ''), p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount, p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by, p.gift_card_id FROM payments p LEFT JOIN user_saved_cards usc ON p.user_saved_card_id = usc.id WHERE p.booking_id = $1 ORDER BY p.created_at ASC `, bookingID) if err != nil { return nil, err } defer rows.Close() var paidAmount, totalVATAmount, totalNetAmount float64 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.CardLast4, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount, &p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees, &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID, ) if err != nil { return nil, err } summary.Payments = append(summary.Payments, p) if p.Status == "completed" { paidAmount += p.Amount if p.VATAmount != nil { totalVATAmount += *p.VATAmount } if p.NetAmount != nil { totalNetAmount += *p.NetAmount } else if p.VATAmount == nil { // Only fallback to gross amount if no VAT was applied at all. // When VAT is present, net_amount is always set by apply_vat_to_payment, // so this fallback only applies to non-VAT payments where net == gross. totalNetAmount += p.Amount } } } summary.PaidAmount = paidAmount summary.TotalVATAmount = totalVATAmount summary.TotalNetAmount = totalNetAmount refundRows, err := db.Conn.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 float64 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.Conn.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, gift_card_id 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, &p.GiftCardID, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } return &p, nil } func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyKey string) (*PaymentRecord, error) { var p PaymentRecord err := db.Conn.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, gift_card_id FROM payments WHERE idempotency_key = $1 `, 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, &p.GiftCardID, ) 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.Conn.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, gift_card_id 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, &p.GiftCardID, ) if err != nil { return nil, err } return &p, nil } func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) { var amount float64 err := db.Conn.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 int64(amount * 100), nil } func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) { var count int err := db.Conn.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.Conn.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) if err != nil { return "", err } return status, nil } // BookingPaymentInfo holds booking-level data needed for payment split decisions. type BookingPaymentInfo struct { StartTime time.Time TotalAmount float64 TotalPaid float64 Status string } // GetBookingPaymentInfo fetches the booking start time, total service amount, and // total completed payments for a booking. func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) { var info BookingPaymentInfo err := db.Conn.QueryRow(ctx, ` SELECT b.start_time, b.status, COALESCE(b.total_amount, 0), COALESCE(pt.total_paid, 0) FROM bookings b LEFT JOIN ( SELECT booking_id, SUM(amount) AS total_paid FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id ) pt ON b.id = pt.booking_id WHERE b.id = $1 `, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid) if err != nil { return nil, err } return &info, nil } func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) (string, error) { var userID string err := db.Conn.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID) if err != nil { return "", err } return userID, nil } func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bookingID string) (int64, error) { var remainingCents int64 err := db.Conn.QueryRow(ctx, ` WITH booking_total AS ( SELECT total_amount AS total_pounds FROM bookings WHERE id = $1 ), paid_total AS ( SELECT COALESCE(SUM(amount), 0) AS paid_pounds FROM payments WHERE booking_id = $1 AND status = 'completed' ) SELECT GREATEST(0, ROUND((bt.total_pounds - pt.paid_pounds) * 100))::bigint FROM booking_total bt, paid_total pt `, bookingID).Scan(&remainingCents) if err != nil { return 0, err } return remainingCents, nil } func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) { rows, err := db.Conn.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 { tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) return err } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() retainedUntil := clock.Now().Add(7 * 365 * 24 * time.Hour) _, err = tx.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) if err != nil { return err } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) return err } return nil } func (s *PaymentService) CreatePaymentMethodFromDetails(ctx context.Context, userID, cardNumber, expiry, cvc string) (*SavedCard, error) { parts := strings.Split(expiry, "/") if len(parts) != 2 { return nil, errors.New("invalid expiry format, use MM/YY") } expMonth, err := strconv.Atoi(parts[0]) if err != nil || expMonth < 1 || expMonth > 12 { return nil, errors.New("invalid expiry month") } expYear, err := strconv.Atoi(parts[1]) if err != nil || expYear < 0 || expYear > 99 { return nil, errors.New("invalid expiry year") } expYear += 2000 // Check if card is expired now := clock.Now() expiryDate := time.Date(expYear, time.Month(expMonth), 1, 0, 0, 0, 0, time.UTC) if expiryDate.Before(now) { return nil, errors.New("card has expired") } cardOnFile, err := SquareClient.CreateCardOnFileRaw(ctx, userID, cardNumber, expMonth, expYear, cvc) if err != nil { return nil, fmt.Errorf("failed to tokenize card: %w", err) } var savedCardID string var isDefault bool err = db.Conn.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) SELECT $1, $2, $3, $4, $5, $6, $7, NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL) RETURNING id, is_default `, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, expMonth, expYear, cardOnFile.Fingerprint).Scan(&savedCardID, &isDefault) if err != nil { return nil, fmt.Errorf("failed to save card: %w", err) } return &SavedCard{ ID: savedCardID, SquareCardID: cardOnFile.CardID, Brand: cardOnFile.Brand, Last4: cardOnFile.Last4, ExpMonth: expMonth, ExpYear: expYear, Fingerprint: cardOnFile.Fingerprint, IsDefault: isDefault, }, nil } func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) { var id string err := db.Conn.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) { return s.GetCardByIDQuerier(ctx, db.Conn, cardID, userID) } // GetCardByIDQuerier is identical to GetCardByID but accepts a db.Querier // so the lookup can be performed inside a transaction. Callers inside an // existing transaction should pass their tx variable instead of db.Conn. func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) { var c SavedCard err := q.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