CI / Env docs check (push) Successful in 25s
CI / Docker compose check (push) Successful in 24s
CI / Frontend deps check (push) Successful in 30s
CI / Frontend major deps (push) Successful in 33s
CI / Nginx config check (push) Successful in 59s
CI / Go build (push) Successful in 1m15s
CI / Secrets scan (push) Successful in 1m16s
CI / Knip (push) Successful in 56s
CI / Frontend a11y check (push) Successful in 55s
CI / Frontend build (push) Successful in 1m27s
CI / go mod tidy (push) Successful in 17s
CI / Go vulnerabilities (push) Successful in 2m17s
CI / Go vet (prod) (push) Successful in 2m46s
CI / Go vet (dev) (push) Successful in 2m50s
CI / Staticcheck (prod) (push) Successful in 2m55s
CI / Staticcheck (dev) (push) Successful in 3m13s
CI / Frontend QC (audit) (push) Successful in 41s
CI / golangci-lint (push) Failing after 3m37s
CI / Frontend QC (typecheck) (push) Successful in 1m30s
CI / Frontend QC (lint) (push) Successful in 2m4s
CI / Security scan (prod) (push) Successful in 4m43s
CI / Security scan (dev) (push) Successful in 4m44s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Svelte strict check (push) Successful in 33s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
584 lines
18 KiB
Go
584 lines
18 KiB
Go
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
|