package payments import ( "context" "crussell/clock" "crussell/db" "crussell/internal/square" "database/sql" "errors" "fmt" "log" "log/slog" "math" "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 Origin string // 'manual' (admin handler) or 'cancellation' (cancellation loop) IdempotencyKey *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, idempotency_key, created_by, created_at, origin ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id `, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Status, record.Reason, record.IdempotencyKey, record.CreatedBy, record.CreatedAt, record.Origin, ).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, idempotency_key, created_by, created_at, origin 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.IdempotencyKey, &r.CreatedBy, &r.CreatedAt, &r.Origin, ) 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 // booking_id and gift_card_id are NULL for gift-card purchases — scan into // NullString to avoid "cannot scan NULL into *string". var bookingID, giftCardID sql.NullString 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, &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, &giftCardID, ) p.BookingID = bookingID.String if giftCardID.Valid { p.GiftCardID = &giftCardID.String } 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 // booking_id / vendor_code / gift_card_id / invoice_number are nullable // (e.g. gift-card purchases have no booking). Scan into Null* and map so a // NULL value doesn't 500 the scan (N-3: the same fix class as // CheckIdempotencyByKey). var bookingID, vendorCode, giftCardID sql.NullString var invoiceNumber sql.NullInt64 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, &bookingID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &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, &giftCardID, ) if err != nil { return nil, err } p.BookingID = bookingID.String if vendorCode.Valid { p.VendorCode = &vendorCode.String } if giftCardID.Valid { p.GiftCardID = &giftCardID.String } if invoiceNumber.Valid { n := int(invoiceNumber.Int64) p.InvoiceNumber = &n } return &p, nil } // GetAlreadyRefundedAmount returns the total refunded amount (in pence) for a // payment, counting both 'completed' and 'pending' refunds. Pending refunds are // counted because a Square call may already be in flight for them — excluding // them would let a concurrent refund over-refund the payment. 'failed' refunds // are excluded: they were definitively rejected by Square and must not block // future refund attempts. 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 IN ('completed', 'pending') `, paymentID).Scan(&amount) if err != nil { return 0, err } return int64(math.Round(amount * 100)), nil } // HasCompletedPayment reports whether the booking has a completed non-tip // payment. 'tip' is deliberately excluded: a tip-only booking (no deposit/full // payment) must NOT be treated as "already paid" for the purposes of allowing a // tip. Note buildSplitRecords' overflow-tip records are also invisible here — // intended (a tip is never evidence of payment), but a caller must not assume // this covers every payment_type. 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 net paid (completed payments minus completed/pending refunds) for a // booking. Refunds are subtracted so cancellation refunds never double-refund // money that has already been returned (e.g. via a manual admin refund). 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) - COALESCE(rr.total_refunded, 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 LEFT JOIN ( SELECT p.booking_id, SUM(r.amount) AS total_refunded FROM refunds r JOIN payments p ON r.payment_id = p.id WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending') GROUP BY p.booking_id ) rr ON b.id = rr.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) CreatePaymentMethodFromToken(ctx context.Context, userID, cardToken string) (*SavedCard, error) { // PCI-DSS: raw PANs are never accepted. The client must supply a Square // Web Payments nonce (cnon:xxx), which the backend tokenizes via the // Cards API — the full PAN exists only inside Square's vault. cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, cardToken) if err != nil { return nil, fmt.Errorf("failed to tokenize card: %w", err) } var savedCardID string var isDefault bool // ON CONFLICT (square_card_id): a response-lost retry re-tokenizes the same // card (CreateCardOnFile's deterministic key returns the same ccof: id), so // the UNIQUE constraint would otherwise 500 on the duplicate. Upsert instead // so the retry returns the existing saved card (N-8). 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) ON CONFLICT (square_card_id) DO UPDATE SET brand = EXCLUDED.brand, last_4 = EXCLUDED.last_4, exp_month = EXCLUDED.exp_month, exp_year = EXCLUDED.exp_year, fingerprint = EXCLUDED.fingerprint, deleted_at = NULL, retained_until = NULL RETURNING id, is_default `, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.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: cardOnFile.ExpMonth, ExpYear: cardOnFile.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