Files
Crussell/backend/handlers/payments/service.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

579 lines
18 KiB
Go

package payments
import (
"context"
"crussell/db"
"crussell/clock"
"crussell/internal/square"
"errors"
"fmt"
"log"
"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 tx.Rollback(ctx)
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