package payments import ( "context" "crussell/db" "crussell/internal/square" "errors" "fmt" "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 } 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 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) (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 float64 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 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 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 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, ) 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 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.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 float64 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 int64(amount * 100), 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) GetBookingRemainingBalanceCents(ctx context.Context, bookingID string) (int64, error) { var remainingCents int64 err := db.DB.QueryRow(ctx, ` WITH booking_total AS ( SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) AS total_pounds FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id WHERE bs.booking_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.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) 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 := time.Now() expiryDate := time.Date(expYear, time.Month(expMonth), 1, 0, 0, 0, 0, now.Location()) 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.DB.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.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