fix: payment API amount precision (pence), partial validation, and create payment method endpoint
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -212,7 +213,7 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||
Status: "COMPLETED",
|
||||
PaymentID: existing.ID,
|
||||
Amount: existing.Amount,
|
||||
Amount: int64(existing.Amount * 100),
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
@@ -227,10 +228,10 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "in_person_card",
|
||||
Status: "completed",
|
||||
Amount: paymentResult.Amount,
|
||||
Amount: float64(paymentResult.Amount) / 100.0,
|
||||
SquarePaymentID: &paymentResult.SquarePayID,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: paymentResult.Fees,
|
||||
Fees: float64(paymentResult.Fees) / 100.0,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -294,6 +295,19 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
if req.PaymentType == "partial" {
|
||||
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get remaining balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -321,7 +335,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
BookingID: existingPayment.BookingID,
|
||||
PaymentType: existingPayment.PaymentType,
|
||||
Status: existingPayment.Status,
|
||||
Amount: existingPayment.Amount,
|
||||
Amount: int64(existingPayment.Amount * 100),
|
||||
CreatedAt: existingPayment.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
@@ -386,10 +400,10 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
PaymentType: req.PaymentType,
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: req.Amount,
|
||||
Amount: float64(req.Amount) / 100.0,
|
||||
SquarePaymentID: &paymentResult.SquarePayID,
|
||||
IdempotencyKey: &req.IdempotencyKey,
|
||||
Fees: fees,
|
||||
Fees: float64(fees) / 100.0,
|
||||
UserSavedCardID: savedCardID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -468,6 +482,46 @@ func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
type CreatePaymentMethodRequest struct {
|
||||
CardNumber string `json:"card_number"`
|
||||
Expiry string `json:"expiry"`
|
||||
CVC string `json:"cvc"`
|
||||
}
|
||||
|
||||
func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req CreatePaymentMethodRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.CardNumber == "" || req.Expiry == "" || req.CVC == "" {
|
||||
http.Error(w, "Card number, expiry, and CVC are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
card, err := service.CreatePaymentMethodFromDetails(r.Context(), userID, req.CardNumber, req.Expiry, req.CVC)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "expired") {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to create payment method: %v", err)
|
||||
http.Error(w, "Failed to add card", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(card)
|
||||
}
|
||||
|
||||
func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
paymentID := chi.URLParam(r, "payment_id")
|
||||
if paymentID == "" {
|
||||
@@ -528,7 +582,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount+alreadyRefunded > payment.Amount {
|
||||
if req.Amount+alreadyRefunded > int64(payment.Amount*100) {
|
||||
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -551,7 +605,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
record := RefundRecord{
|
||||
PaymentID: paymentID,
|
||||
BookingID: payment.BookingID,
|
||||
Amount: req.Amount,
|
||||
Amount: float64(req.Amount) / 100.0,
|
||||
SquareRefundID: &squareRefundID,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
@@ -566,7 +620,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if payment.PaymentType == "deposit" && (req.Amount+alreadyRefunded) >= payment.Amount {
|
||||
if payment.PaymentType == "deposit" && (req.Amount+alreadyRefunded) >= int64(payment.Amount*100) {
|
||||
err = service.UpdateBookingDepositPaid(r.Context(), payment.BookingID, false)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update deposit paid: %v", err)
|
||||
@@ -667,7 +721,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: req.Amount,
|
||||
Amount: float64(req.Amount) / 100.0,
|
||||
SquarePaymentID: &paymentResult.SquarePayID,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: 0,
|
||||
@@ -741,7 +795,8 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
|
||||
BookingID: p.BookingID,
|
||||
PaymentType: p.PaymentType,
|
||||
Status: p.Status,
|
||||
Amount: p.Amount,
|
||||
Amount: int64(p.Amount * 100),
|
||||
CardLast4: p.CardLast4,
|
||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -751,7 +806,7 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
|
||||
refunds[i] = RefundResponse{
|
||||
ID: rf.ID,
|
||||
PaymentID: rf.PaymentID,
|
||||
Amount: rf.Amount,
|
||||
Amount: int64(rf.Amount * 100),
|
||||
Status: rf.Status,
|
||||
Reason: rf.Reason,
|
||||
CreatedAt: rf.CreatedAt.Format(time.RFC3339),
|
||||
@@ -760,10 +815,10 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentSummaryResponse{
|
||||
TotalAmount: summary.TotalAmount,
|
||||
PaidAmount: summary.PaidAmount,
|
||||
RefundedAmount: summary.RefundedAmount,
|
||||
RemainingAmount: summary.RemainingAmount,
|
||||
TotalAmount: int64(summary.TotalAmount * 100),
|
||||
PaidAmount: int64(summary.PaidAmount * 100),
|
||||
RefundedAmount: int64(summary.RefundedAmount * 100),
|
||||
RemainingAmount: int64(summary.RemainingAmount * 100),
|
||||
Payments: payments,
|
||||
Refunds: refunds,
|
||||
})
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"crussell/testutils/testdb"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -140,7 +139,7 @@ func splitToken(token string) []string {
|
||||
}
|
||||
|
||||
func base64URLDecode(s string) ([]byte, error) {
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func extractPaymentIDFromPath(path string) (string, string) {
|
||||
@@ -150,7 +149,7 @@ func extractPaymentIDFromPath(path string) (string, string) {
|
||||
}{
|
||||
{"/api/admin/payments/", "payment_id"},
|
||||
{"/api/admin/bookings/", "id"},
|
||||
{"/api/admin/bookings/", "id"},
|
||||
{"/api/bookings/", "id"},
|
||||
{"/api/user/payment-methods/", "id"},
|
||||
}
|
||||
for _, p := range patterns {
|
||||
@@ -222,8 +221,6 @@ func TestTerminalPayment_HappyPath(t *testing.T) {
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 payments (created on completion), got %d", count)
|
||||
}
|
||||
|
||||
_ = userID
|
||||
}
|
||||
|
||||
func setupTestData(t *testing.T) (string, string, string) {
|
||||
@@ -519,7 +516,12 @@ func TestRefund_FullRefund(t *testing.T) {
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
@@ -563,7 +565,12 @@ func TestRefund_PartialRefund(t *testing.T) {
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
@@ -605,7 +612,7 @@ func TestRefund_OverRefundRejected(t *testing.T) {
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
@@ -771,7 +778,7 @@ func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w.Body.String())
|
||||
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var resp2 PaymentResponse
|
||||
@@ -851,3 +858,778 @@ func TestSquareWebhook_DevMode_NoSignature(t *testing.T) {
|
||||
_ = w
|
||||
t.Skip("webhook handler tested in webhooks package")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// User Booking Payment Tests — deposit, full, partial, balance
|
||||
// ============================================================
|
||||
|
||||
func setupDepositBooking(t *testing.T) (string, string) {
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"UPDATE bookings SET status = 'confirmed', deposit_required = TRUE WHERE id = $1",
|
||||
bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update booking: %v", err)
|
||||
}
|
||||
|
||||
return userID, bookingID
|
||||
}
|
||||
|
||||
func TestBookingPayment_Deposit_HappyPath(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:deposit-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: false,
|
||||
IdempotencyKey: "deposit-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp PaymentResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.PaymentType != "deposit" {
|
||||
t.Errorf("expected payment type deposit, got %s", resp.PaymentType)
|
||||
}
|
||||
|
||||
if resp.Amount != 2500 {
|
||||
t.Errorf("expected amount 2500, got %d", resp.Amount)
|
||||
}
|
||||
|
||||
if resp.Status != "completed" {
|
||||
t.Errorf("expected status completed, got %s", resp.Status)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 deposit payment, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_FullPayment(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:full-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "full-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp PaymentResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.PaymentType != "full" {
|
||||
t.Errorf("expected payment type full, got %s", resp.PaymentType)
|
||||
}
|
||||
|
||||
if resp.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000, got %d", resp.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_PartialPayment(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:partial-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 1500,
|
||||
PaymentType: "partial",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "partial-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp PaymentResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.PaymentType != "partial" {
|
||||
t.Errorf("expected payment type partial, got %s", resp.PaymentType)
|
||||
}
|
||||
|
||||
if resp.Amount != 1500 {
|
||||
t.Errorf("expected amount 1500, got %d", resp.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_BalancePayment(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:balance-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 3500,
|
||||
PaymentType: "balance",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "balance-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp PaymentResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.PaymentType != "balance" {
|
||||
t.Errorf("expected payment type balance, got %s", resp.PaymentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_ZeroAmountRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:zero-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 0,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "zero-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_NegativeAmountRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:neg-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: -100,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "neg-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:invalid-type-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "invalid_type",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "invalid-type-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_NoAuthRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID := setupDepositBooking(t)
|
||||
|
||||
cardToken := "cnon:no-auth-card"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "no-auth-test-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, "")
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_DepositFollowedByBalance(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:deposit-balance-card"
|
||||
|
||||
req1 := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "deposit-balance-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Errorf("deposit: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
req2 := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "balance",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "deposit-balance-2",
|
||||
}
|
||||
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 payments, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingPayment_PartialFollowedByBalance(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID := setupDepositBooking(t)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:partial-balance-card"
|
||||
|
||||
req1 := CreateBookingPaymentRequest{
|
||||
Amount: 1000,
|
||||
PaymentType: "partial",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "partial-balance-1",
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Errorf("partial: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
req2 := CreateBookingPaymentRequest{
|
||||
Amount: 4000,
|
||||
PaymentType: "balance",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "partial-balance-2",
|
||||
}
|
||||
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query payments: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 payments, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTipPayment_WrongOwnerRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
otherUserID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create other user: %v", err)
|
||||
}
|
||||
|
||||
otherToken := jwt.GenerateUserToken(otherUserID)
|
||||
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardToken: "cnon:wrong-owner-tip",
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, otherToken)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTipPayment_MultipleTipsAllowed(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(db.DB, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: int64(200 + i*100),
|
||||
CardToken: fmt.Sprintf("cnon:multi-tip-%d", i),
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("tip %d: expected status 200, got %d. body: %s", i, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
var count int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query tip payments: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3 tip payments, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserPaymentMethods_NoCards(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
handler := GetUserPaymentMethods
|
||||
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var cards []SavedCard
|
||||
if err := parsePaymentResponseBody(w, &cards); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if len(cards) != 0 {
|
||||
t.Errorf("expected 0 cards, got %d", len(cards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(db.DB, userID, "cfa_wrong_owner", "VISA", "0000")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment method: %v", err)
|
||||
}
|
||||
|
||||
otherUserID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create other user: %v", err)
|
||||
}
|
||||
|
||||
otherToken := jwt.GenerateUserToken(otherUserID)
|
||||
|
||||
handler := DeletePaymentMethod
|
||||
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, otherToken)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var count int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL", cardID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query card: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Error("expected card to still exist (not deleted by wrong owner)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePartialAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amountCents int64
|
||||
remainingCents int64
|
||||
expectErr bool
|
||||
}{
|
||||
{"valid partial", 500, 1000, false},
|
||||
{"exact remaining", 1000, 1000, false},
|
||||
{"exceeds remaining", 1500, 1000, true},
|
||||
{"zero amount", 0, 1000, true},
|
||||
{"negative amount", -100, 1000, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePartialAmount(tt.amountCents, tt.remainingCents)
|
||||
if tt.expectErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.expectErr && err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
initialRemaining, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if initialRemaining <= 0 {
|
||||
t.Fatalf("expected positive remaining balance, got %d", initialRemaining)
|
||||
}
|
||||
|
||||
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "partial",
|
||||
PaymentMethod: "cash",
|
||||
Status: "completed",
|
||||
Amount: 20.00,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
afterPartial, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if afterPartial != initialRemaining-2000 {
|
||||
t.Errorf("expected %d cents remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
|
||||
}
|
||||
|
||||
_, err = service.CreatePaymentRecord(context.Background(), PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "balance",
|
||||
PaymentMethod: "cash",
|
||||
Status: "completed",
|
||||
Amount: float64(afterPartial) / 100.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
afterFull, err := service.GetBookingRemainingBalanceCents(context.Background(), bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if afterFull != 0 {
|
||||
t.Errorf("expected 0 cents remaining after full payment, got %d", afterFull)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMethod_HappyPath(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
handler := CreatePaymentMethod
|
||||
reqBody := CreatePaymentMethodRequest{
|
||||
CardNumber: "4111111111111111",
|
||||
Expiry: "12/30",
|
||||
CVC: "123",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
var card SavedCard
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if card.Brand != "VISA" {
|
||||
t.Errorf("expected brand VISA, got %s", card.Brand)
|
||||
}
|
||||
if card.Last4 != "1111" {
|
||||
t.Errorf("expected last4 1111, got %s", card.Last4)
|
||||
}
|
||||
if !card.IsDefault {
|
||||
t.Error("expected first card to be default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMethod_ExpiredCardRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
handler := CreatePaymentMethod
|
||||
reqBody := CreatePaymentMethodRequest{
|
||||
CardNumber: "4111111111111111",
|
||||
Expiry: "01/20",
|
||||
CVC: "123",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMethod_InvalidExpiryRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expiry string
|
||||
}{
|
||||
{"bad format", "12-30"},
|
||||
{"bad month", "13/30"},
|
||||
{"bad year", "12/abc"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := CreatePaymentMethod
|
||||
reqBody := CreatePaymentMethodRequest{
|
||||
CardNumber: "4111111111111111",
|
||||
Expiry: tt.expiry,
|
||||
CVC: "123",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body CreatePaymentMethodRequest
|
||||
}{
|
||||
{"no card number", CreatePaymentMethodRequest{Expiry: "12/30", CVC: "123"}},
|
||||
{"no expiry", CreatePaymentMethodRequest{CardNumber: "4111111111111111", CVC: "123"}},
|
||||
{"no cvc", CreatePaymentMethodRequest{CardNumber: "4111111111111111", Expiry: "12/30"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := CreatePaymentMethod
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", tt.body, token)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMethod_NoAuthRejected(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
handler := CreatePaymentMethod
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{
|
||||
CardNumber: "4111111111111111",
|
||||
Expiry: "12/30",
|
||||
CVC: "123",
|
||||
}, "")
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create first card
|
||||
handler := CreatePaymentMethod
|
||||
reqBody := CreatePaymentMethodRequest{
|
||||
CardNumber: "4111111111111111",
|
||||
Expiry: "12/30",
|
||||
CVC: "123",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("failed to create first card: %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
reqBody2 := CreatePaymentMethodRequest{
|
||||
CardNumber: "5500000000000004",
|
||||
Expiry: "06/30",
|
||||
CVC: "456",
|
||||
}
|
||||
|
||||
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("failed to create second card: %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var card SavedCard
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if card.Brand != "MASTERCARD" {
|
||||
t.Errorf("expected brand MASTERCARD, got %s", card.Brand)
|
||||
}
|
||||
if card.IsDefault {
|
||||
t.Error("expected second card to NOT be default")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -35,15 +38,16 @@ type PaymentRecord struct {
|
||||
VendorCode *string
|
||||
InvoiceNumber *int
|
||||
Status string
|
||||
Amount int64
|
||||
Amount float64
|
||||
CardLast4 string
|
||||
IsVATApplicable bool
|
||||
VATRate *float64
|
||||
VATAmount *int64
|
||||
NetAmount *int64
|
||||
VATAmount *float64
|
||||
NetAmount *float64
|
||||
UserSavedCardID *string
|
||||
SquarePaymentID *string
|
||||
IdempotencyKey *string
|
||||
Fees int64
|
||||
Fees float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
@@ -53,7 +57,7 @@ type RefundRecord struct {
|
||||
ID string
|
||||
PaymentID string
|
||||
BookingID string
|
||||
Amount int64
|
||||
Amount float64
|
||||
SquareRefundID *string
|
||||
Status string
|
||||
Reason string
|
||||
@@ -62,19 +66,19 @@ type RefundRecord struct {
|
||||
}
|
||||
|
||||
type PaymentSummary struct {
|
||||
TotalAmount int64
|
||||
PaidAmount int64
|
||||
RefundedAmount int64
|
||||
RemainingAmount int64
|
||||
TotalAmount float64
|
||||
PaidAmount float64
|
||||
RefundedAmount float64
|
||||
RemainingAmount float64
|
||||
Payments []PaymentRecord
|
||||
Refunds []RefundRecord
|
||||
}
|
||||
|
||||
func (s *PaymentService) CalculateFees(amount int64, method string) int64 {
|
||||
func (s *PaymentService) CalculateFees(amount int64, method string) float64 {
|
||||
if method == "online" {
|
||||
return (amount * 14 / 1000) + 25
|
||||
return float64((amount * 14 / 1000) + 25) / 100.0
|
||||
}
|
||||
return (amount * 175 / 10000)
|
||||
return float64(amount * 175 / 10000) / 100.0
|
||||
}
|
||||
|
||||
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord) (string, error) {
|
||||
@@ -143,7 +147,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
Refunds: []RefundRecord{},
|
||||
}
|
||||
|
||||
var totalAmount int64
|
||||
var totalAmount float64
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(
|
||||
COALESCE(bs.override_price, s.price)
|
||||
@@ -159,12 +163,13 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
summary.TotalAmount = totalAmount
|
||||
|
||||
rows, err := db.DB.Query(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
|
||||
ORDER BY created_at ASC
|
||||
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 {
|
||||
@@ -172,12 +177,12 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var paidAmount int64
|
||||
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.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&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,
|
||||
)
|
||||
@@ -203,7 +208,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
}
|
||||
defer refundRows.Close()
|
||||
|
||||
var refundedAmount int64
|
||||
var refundedAmount float64
|
||||
for refundRows.Next() {
|
||||
var r RefundRecord
|
||||
err := refundRows.Scan(
|
||||
@@ -268,7 +273,7 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
|
||||
var amount int64
|
||||
var amount float64
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0) FROM refunds
|
||||
WHERE payment_id = $1 AND status = 'completed'
|
||||
@@ -277,7 +282,7 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return amount, nil
|
||||
return int64(amount * 100), nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) UpdateBookingDepositPaid(ctx context.Context, bookingID string, depositPaid bool) error {
|
||||
@@ -318,6 +323,29 @@ func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string)
|
||||
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
|
||||
@@ -359,6 +387,57 @@ func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID
|
||||
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, `
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package payments
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Valid payment types
|
||||
var validPaymentTypes = map[string]bool{
|
||||
@@ -11,11 +14,25 @@ var validPaymentTypes = map[string]bool{
|
||||
"partial": true,
|
||||
}
|
||||
|
||||
// ValidateAmount checks that amount is greater than 0
|
||||
// ValidateAmount checks that amount is greater than 0 and has valid precision (max 2 decimal places when in pence)
|
||||
func ValidateAmount(amount int64) error {
|
||||
if amount <= 0 {
|
||||
return errors.New("amount must be greater than 0")
|
||||
}
|
||||
// Amount is in pence (integer), so no precision issues possible at this level
|
||||
// The frontend must ensure the input has max 2 decimal places before converting to pence
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePartialAmount checks that the partial amount doesn't exceed the remaining balance
|
||||
func ValidatePartialAmount(amountCents int64, remainingCents int64) error {
|
||||
if amountCents > remainingCents {
|
||||
return fmt.Errorf("partial amount (£%.2f) exceeds remaining balance (£%.2f)",
|
||||
float64(amountCents)/100, float64(remainingCents)/100)
|
||||
}
|
||||
if amountCents <= 0 {
|
||||
return errors.New("amount must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user