Files
Crussell/backend/handlers/payments/service.go
T

569 lines
18 KiB
Go

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
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
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.DB)
}
// 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.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(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) price_sub
`, 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,
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 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
}
}
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,
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.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,
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.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,
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.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) 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
}
// 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.DB.QueryRow(ctx, `
SELECT b.start_time, b.status,
COALESCE(bt.total_amount, 0),
COALESCE(pt.total_paid, 0)
FROM bookings b
LEFT JOIN (
SELECT booking_id, COALESCE(SUM(price_val), 0) AS total_amount FROM (
SELECT bs.booking_id, COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT bcs.booking_id, COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub GROUP BY booking_id
) bt ON b.id = bt.booking_id
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.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(price_val), 0) AS total_pounds FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) price_sub
),
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